Prefácio
Esse site visa apresentar conceitos de processamento digital de imagens, além da resolução de exercícios propostos durante as aulas da disciplina DCA0445. Dessa forma, para essa disciplina, processamento digital de imagens, precisa-se utilizar da linguagem de programação C++ em conjunto com algum software que possua suporte para as bibliotecas do OpenCV.
Os exemplos descritos abaixo foram desenvolvidos usando a API C++ do OpenCV e compilados na plataforma do Visual Studio 2022 (https://visualstudio.microsoft.com/pt-br/vs/). Foram testados em um ambiente executando sistema operacional Windows, mas devem funcionar corretamente em outras plataformas.
OpenCV
Para o desenvolvimento das aplicações e exercícios propostos, o OpenCV (Open Source Computer Vision Library:
http://www.opencv.org) que é uma
biblioteca (ou conjunto de bibliotecas) de código aberto disponível para algumas
linguagens de programação que oferece uma grande variedade de possibilidades e ferramentas úteis para
processamento de imagens entre outras áreas gráficas.
OBS: É importante salientar que a instalação e configuração do software utilizado para
desenvolver aplicações com OpenCV não serão relatados, mas podem ser encontrados em conteúdos disponibilizados nos mecanismos de busca da internet.
Parte I: Processamento de Imagens no Domínio Espacial
1. Conceitos iniciais
Nesta página, iremos abordar sobre diversos conceitos ligados ao processamento de imagens no domínio espacial, tendo como foco as áreas de:
manipulação de imagens e histogramas, serialização de dados, decomposição de imagens, preenchimento de regiões e aplicações de métodos de filtragem.
2. Manipulando pixels em uma imagem
2.1. Exercício regions.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int, char**) {
cv::Mat image = cv::imread("onca.jpg", cv::IMREAD_COLOR);
if (!image.data) {
std::cout << "Erro ao abrir a imagem!" << std::endl;
return -1;
}
// Máscara negativa
cv::Rect mascara(100, 100, 200, 200);
// Percorre os pixels da máscara da imagem invertendo os pixels
for (int y = 100; y < 300; y++) {
for (int x = 100; x < 300; x++) {
// Obtém o valor do pixel na posição (x, y)
cv::Vec3b pixel = image.at<cv::Vec3b>(y, x);
// Inverte cada canal do pixel
cv::Vec3b inversao;
inversao[0] = 255 - pixel[0]; // Canal azul
inversao[1] = 255 - pixel[1]; // Canal verde
inversao[2] = 255 - pixel[2]; // Canal vermelho
// Atribui o pixel invertido à imagem resultante
image.at<cv::Vec3b>(y, x) = inversao;
}
}
// Exibe a imagem resultante
cv::namedWindow("Imagem com Região Negativa", cv::WINDOW_AUTOSIZE);
cv::imshow("Imagem com Região Negativa", image);
cv::waitKey(0);
return 0;
}
Figura 3. Sistema referencial adotado no OpenCV.
2.2. Exercício trocaregioes.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>
# define M_PI 3.14159265358979323846
int SIDE = 400;
int PERIODOS = 4;
int main(int argc, char** argv) {
std::stringstream ss_img, ss_yml;
cv::Mat image;
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * PERIODOS * i / SIDE) + 128;
}
}
fs << "mat" << image;
fs.release();
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
fs.open(ss_yml.str(), cv::FileStorage::READ);
fs["mat"] >> image;
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
cv::imshow("image", image);
cv::waitKey();
return 0;
}
Figura 3. Sistema referencial adotado no OpenCV.
3. Serialização de dados em ponto flutuante via FileStorage
Nem todo tipo de imagem pode ser armazenado em arquivos com formatos comuns como
JPEG ou PNG. Esses formatos suportam apenas imagens convencionais, cujo tipo de
dado associado ao pixel normalmente é um unsigned char. Entretanto, em muitos
casos, é necessário armazenar imagens com dados de ponto flutuante (float ou
double), como por exemplo, imagens de alta dinâmica, imagens HDR, imagens de
profundidade, máscaras usadas em filtros digitais, funções de transferência
usadas para filtragem no domínio da frequência etc. Para isso, o OpenCV
disponibiliza a classe cv::FileStorage, que permite armazenar dados em
arquivos com formatos mais genéricos, como XML ou YAML. Essa classe é muito útil
para armazenar dados de forma estruturada, como por exemplo, dados de uma
imagem, como largura, altura, número de canais, tipo de dado, etc. Assim,
matrizes representadas em ponto flutuante podem ser guardadas para uso
posterior.
Nesta lição, será mostrado como armazenar e recuperar dados em ponto flutuante
em um arquivo codificado em YAML. Para isso, será criada uma matriz de pixels do
tipo float e armazenada em um arquivo YAML. Em seguida, será recuperada a
matriz do arquivo YAML, processada e exibida na tela.
Para criar a matriz de float e armazená-la em um arquivo, realize o download
do programa filestorage.cpp, mostrado na
Listagem 4.
Listagem 4. filestorage.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>
int SIDE = 256;
int PERIODOS = 8;
int main(int argc, char** argv) {
std::stringstream ss_img, ss_yml;
cv::Mat image;
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * PERIODOS * j / SIDE) + 128;
}
}
fs << "mat" << image;
fs.release();
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
fs.open(ss_yml.str(), cv::FileStorage::READ);
fs["mat"] >> image;
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
cv::imshow("image", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa
filestorage.cpp, salve-o juntamente com o arquivo
Makefile em um diretório e execute a seguinte
seqüência de comandos:
$ make filestorage
$ ./filestorage
A saída do programa filestorage é mostrado na Figura 6
Figura 6. Saída do programa filestorage
3.1. Descrição do programa filestorage.cpp
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
A primeira linha usa a classe stringstream para criar um string com o nome do arquivo de saída. O nome do arquivo é formado pela concatenação da string "senoide-" com o valor da constante SIDE e a extensão ".yml". Perceba que é possível alterar o arquivo de saída para se amoldar ao tamanho desejado para o lado da imagem. A segunda linha cria uma matriz de float de tamanho SIDE x SIDE e inicializa todos os elementos com o valor 0. O tipo CV_32FC1 representa um dado em OpenCV de 32 bits em ponto flutuante com apenas um canal (o equivalente ao tipo float). A terceira linha cria um objeto da classe FileStorage para armazenar dados em um arquivo. O primeiro parâmetro é o nome do arquivo de saída, o segundo parâmetro é o modo de abertura do arquivo. Neste caso, o modo de abertura é cv::FileStorage::WRITE, o que indica que o arquivo será aberto para escrita, permitindo a gravação da imagem gerada em ponto flutuante.
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * FREQUENCIA * j / SIDE) + 128;
}
}
O laço aninhado percorre todos os elementos da matriz image e atribui a cada elemento um valor de brilho correspondente à amplitude da senóide naquele ponto. Perceba que na imagem gerada a senóide percorre um total de 8 períodos ao longo de cada linha, o que equivale exatamente ao valor da variável FREQUENCIA. O valor da senoide é multiplicado por 127 e somado a 128 para que o valor da senóide fique entre 0 e 255.
fs << "mat" << image;
fs.release();
O objeto fs recebe a serialização dos dados da matriz image associados com o identificador literal "mat". Durante o processo de deserialização, o identificador literal "mat" será usado para recuperar os dados da matriz image. O método release() fecha o arquivo de saída. As linhas mostradas na Listagem 5 são extraídas do início do arquivo senoide-256.yml que é gerado pelo programa filestorage.cpp. Perceba que o arquivo codificado em YAML é escrito em texto simples, composto por uma sequência de pares chave-valor, onde a chave é o identificador literal "mat" e o valor é a matriz de float serializada.
Listagem 5. trecho do arquivo senoide-256.yml
%YAML:1.0
---
mat: !!opencv-matrix
rows: 256
cols: 256
dt: f
data: [ 128., 1.52776474e+02, 1.76600800e+02, 1.98557419e+02,
2.17802567e+02, 2.33596634e+02, 2.45332703e+02, 2.52559738e+02,
255., 2.52559738e+02, 2.45332703e+02, 2.33596634e+02,
2.17802567e+02, 1.98557419e+02, 1.76600800e+02, 1.52776474e+02,
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
A mesma imagem é também gravada no formato PNG, para que possa ser visualizada.
Para isso, é necessário converter a matriz de float para o tipo CV_8U, que
representa um dado em OpenCV de 8 bits sem sinal com apenas um canal (o
equivalente ao tipo unsigned char). O método normalize() é usado para
normalizar os valores da matriz de float para o intervalo \$0, 255\$. O
método convertTo() converte a matriz de float para o tipo CV_8U para que a
gravação no arquivo determinado se dê sem intercorrências. Perceba que a classe
stringstream foi novamente usada para proceder com a criação do nome do
arquivo. A figura Figura 7 mostra a imagem gerada pelo programa
Figura 7. Imagem da senoide gerada pelo programa filestorage.cpp
3.2. Exercícios
-
Utilizando o programa filestorage.cpp como
base, crie um programa que gere uma imagem de dimensões 256x256 pixels
contendo uma senóide de 4 períodos com amplitude de 127 desenhada na
horizontal, como aquela apresentada na Figura 6 . Grave a imagem no formato PNG e
no formato YML. Compare os arquivos gerados, extraindo uma linha de cada
imagem gravada e comparando a diferença entre elas. Trace um gráfico da
diferença calculada ao longo da linha correspondente extraída nas imagens. O que você observa?
4. Decomposição de imagens em planos de bits
Com apenas 8 bits é possível representar cada componente de cor em uma imagem em uma faixa de variação de 0 a 255. Apesar ter apenas um byte de tamanho, essa quantidade permite enganar com maestria o olho humano e ainda possibilita uma gama de aproximadamente 16 milhões de tonalidades de cores para compor uma imagem.
Os bits mais significativos dos pixels de uma imagem guardam as informações mais importantes para a composição da cor, ao passo que menos significativos pouca informação detém para olhos medianos. Observe, por exemplo, a sequência de imagens na Figura 8. Ela apresenta os planos de bits da imagem biel.png, onde cada uma mostra valores iguais a 0 ou 255, ou seja, são imagens monocromáticas com um bit por pixel. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo. Perceba que nos planos de bits menos significativos pouca informação sobre a imagem é revelada, enquanto nos planos de bits mais significativos a imagem é revelada com mais detalhes.
Figura 8. Planos de bits em uma imagem
A imagem seguinte foi composta manipulando os bits de cada pixel de forma que os N bits menos significativos de cada componente de cor fossem deixados com valores iguais a zero para seis valores distintos de N, variando de 0 (canto superior esquerdo) a 7 (canto inferior direito).
Figura 9. Anulando planos de bits
Perceba como ocorre a degradação das cores da imagem na medida em que os bits são descartados. Para a imagem do exemplo, a degradação começa a ser percebida com a perda de 3 ou 4 bits menos significativos. É justamente aí que entra a possibilidade de usar os bits menos significativos da figura para ocultar informação, posto que sua influência geralmente não é perceptível na imagem.
4.1. Esteganografia em imagens digitais
Esteganografia é uma área da criptologia que se ocupa de ocultar uma informação em outra, de sorte a tornar despercebida uma determinada mensagem. Ela pode ser feita no computador usando arquivos de texto, imagens ou vídeos, de modo que apenas o receptor que conhece como a ocultação foi realizada saiba como recuperar a informação inserida. Na esteganografia, usa-se o princípio da ocultação por obscuridade, onde pressupõe-se que apenas o remetente e o destinatário sabem como decifrar o segredo enviado.
Embora esteja um desuso pelos algoritmos modernos de criptografia, ainda é possível se divertir um pouco com isso, combinando esteganografia com imagens digitais. A ideia é esconder uma imagem secreta einformáticam outra (imagem portadora), mas sem alterar significativamente a aparência da portadora.
Descartando-se uma quantidade de bits menos significativos de cada pixel, suficiente para não perder a qualidade visual da imagem, pode-se usar posições dos bits perdidos para esconder informação. Dá-se a essa prática o nome de esteganografia de bits menos significativos, ou Least Significant Bit steganography.
A ideia é esconder a imagem biel.jpg na imagem sushi.jpg, de modo que a imagem resultante não apresente diferenças significativas em relação à imagem portadora.
Figura 10. Imagem portadora e imagem escondida
Para isso, serão preservados os 5 bits mais significativos (MSB) dos pixels da imagem portadora e os 3 bits mais significativos da imagem escondida serão colocados no lugar dos 3 bits menos significativos (LSB) da imagem portadora, como ilustra a Figura 11. É importante observar que ambas as imagem escondida deve ter no máximo o tamanho da imagem portadora.
Figura 11. Composição de bits da Imagem portadora e imagem escondida
Na Listagem 6 que segue é mostrado como esconder o conteúdo de uma imagem em outra utilizando operadores de manipulação de bits com OpenCV.
Listagem 6. esteg-encode.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char**argv) {
cv::Mat imagemPortadora, imagemEscondida, imagemFinal;
cv::Vec3b valPortadora, valEscondida, valFinal;
int nbits = 3;
imagemPortadora = cv::imread(argv[1], cv::IMREAD_COLOR);
imagemEscondida = cv::imread(argv[2], cv::IMREAD_COLOR);
if (imagemPortadora.empty() || imagemEscondida.empty()) {
std::cout << "imagem nao carregou corretamente" << std::endl;
return (-1);
}
if (imagemPortadora.rows != imagemEscondida.rows ||
imagemPortadora.cols != imagemEscondida.cols) {
std::cout << "imagens devem ter o mesmo tamanho" << std::endl;
return (-1);
}
imagemFinal = imagemPortadora.clone();
for (int i = 0; i < imagemPortadora.rows; i++) {
for (int j = 0; j < imagemPortadora.cols; j++) {
valPortadora = imagemPortadora.at<cv::Vec3b>(i, j);
valEscondida = imagemEscondida.at<cv::Vec3b>(i, j);
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valPortadora[1] = valPortadora[1] >> nbits << nbits;
valPortadora[2] = valPortadora[2] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valEscondida[1] = valEscondida[1] >> (8-nbits);
valEscondida[2] = valEscondida[2] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
valFinal[1] = valPortadora[1] | valEscondida[1];
valFinal[2] = valPortadora[2] | valEscondida[2];
imagemFinal.at<cv::Vec3b>(i, j) = valFinal;
}
}
imwrite("esteganografia.png", imagemFinal);
return 0;
}
Para compilar e executar o programa
esteg-encode.cpp, salve-o juntamente com os arquivo
Makefile e a imagens
sushi.jpg biel.jpg em um diretório e execute a seguinte
seqüência de comandos:
$ make esteg-encode
$ ./esteg-encode sushi.jpg biel.jpg
4.2. Descrição do programa
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
A primeira linha desse trecho faz com que os N bits menos significativos da imagem portadora sejam anulados, onde N é o número de bits que serão usados para esconder a imagem codificada. A segunda linha faz com que os N bits mais significativos da imagem codificada sejam deslocados à direita para a posição dos N bits menos significativos na variável. A terceira linha faz a combinação dos valores das duas imagens, de modo que os N bits menos significativos da imagem portadora sejam substituídos pelos N bits mais significativos da imagem codificada.
A imagem resultante da esteganografia será gravada no arquivo esteganografia.png. O resultado da esteganografia é mostrado na Figura 12. Observe que a imagem resultante não apresenta diferenças visuais significativas em relação à imagem portadora.
Figura 12. Imagem resultante da esteganografia
A decomposição em planos de bits da imagem resultante da esteganografia mostra que os bits menos significativos da imagem portadora foram substituídos pelos bits mais significativos da imagem codificada, conforme mostra a Figura 13. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo.
Figura 13. Planos de bits em uma imagem
4.3. Exercícios
-
Usando o programa esteg-encode.cpp como referência para esteganografia, escreva um programa que recupere a imagem codificada de uma imagem resultante de esteganografia. Lembre-se que os bits menos significativos dos pixels da imagem fornecida deverão compor os bits mais significativos dos pixels da imagem recuperada. O programa deve receber como parâmetros de linha de comando o nome da imagem resultante da esteganografia. Teste a sua implementação com a imagem da Figura 14 (desafio-esteganografia.png).
Figura 14. Imagem codificada
5. Preenchendo regiões
Uma tarefa bastante comum em processamento de imagens e visão
artificial é contar a quantidade de objetos presentes em uma cena.
Para contar os objetos é necessário identificar os aglomerados de
pixels associados a cada um. Neste exemplo, assume-se que a imagem é
do tipo binária, ou seja, cada pixel assume apenas dois valores - 0 ou
255 - indicando que o pixel pertence ao fundo da imagem ("0") ou a
algum objeto presente ("255"). Assume-se também que cada aglomerado de
pixels será interpretado como um objeto individual. Esse é o processo
mais comum para operações de contagem de objetos em uma imagem.
Uma das maneiras de identificar as regiões de forma única é através de
rotulação. A rotulação de regiões é o processo pelo qual regiões com
características comuns recebem um identificador comum (rótulo).
Em geral, um algoritmo de rotulação de imagens binárias recebe como
entrada uma imagem binária e fornece como saída uma imagem em tons de
cinza, com as várias regiões representativas de objetos rotuladas com
um tom de cinza diferente.
No exemplo dessa lição será mostrado como rotular uma imagem binária,
utilizando o algoritmo floodfill (ou seedfill) para descobrir os
aglomerados de pixels. A imagem usada para teste será a presente no arquivo
bolhas.png mostrada na Figura Bolhas.
Figura 15. Imagem bolhas.png
O programa de referência utilizado para essa tarefa,
labeling.cpp, é mostrado na Listagem
Labeling.
Listagem 7. labeling.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
using namespace cv;
int main(int argc, char** argv) {
cv::Mat image, realce;
int width, height;
int nobjects;
cv::Point p;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
if (!image.data) {
std::cout << "imagem nao carregou corretamente\n";
return (-1);
}
width = image.cols;
height = image.rows;
std::cout << width << "x" << height << std::endl;
p.x = 0;
p.y = 0;
// busca objetos presentes
nobjects = 0;
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
if (image.at<uchar>(i, j) == 255) {
// achou um objeto
nobjects++;
// para o floodfill as coordenadas
// x e y são trocadas.
p.x = j;
p.y = i;
// preenche o objeto com o contador
cv::floodFill(image, p, nobjects);
}
}
}
std::cout << "a figura tem " << nobjects << " bolhas\n";
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa labeling.cpp,
salve-o juntamente com os arquivo Makefile e
bolhas.png em um diretório e execute a seguinte seqüência de comandos:
$ make labeling
$ ./labeling bolhas.png
A saída do programa labeling é mostrado na Figura Labeling
Figura 16. Saída do programa labeling
5.1. Descrição do programa labeling.cpp
cv::Point p;
A estrutura Point define um ponto na segunda dimensão que permite
acesso às suas coordenadas x e y. Ele será usado no exemplo para
indicar a semente de preenchimento que é usada pelo algoritmo floodfill.
image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);
Independentemente do formato da imagem de entrada, ela será convertida
para tons de cinza, uma vez que o exemplo assume essa condição.
p.x=0;
p.y=0;
Nesta fase tem início o processo de rotulação das várias regiões da
imagem. Assumindo que os pixels do objeto possuem tom de cinza igual a
255, o algoritmo percorre toda a imagem, linha após linha, de cima a
baixo, da esquerda para direita por pixels que tenham tom igual
a 255.
Quando um elemento da matriz é encontrado com tom de cinza igual a
255, o algoritmo floodfill é executado utilizando as coordenadas
desse ponto como semente.
A operação do algoritmo floodfill é bem simples: dado um ponto
semente, o algoritmo sai procurando os 4- ou 8-vizinhos desse ponto
(conforme configuração estabelecida) que possuem a mesma propriedade
do ponto semente (geralmente o tom de cinza). Para cada ponto
encontrado, muda-se sua propriedade para uma nova propriedade
fornecida. Para cada ponto encontrado, também, realiza-se a busca de
vizinhança para os seus 4- ou 8-vizinhos que contenham a mesma
propriedade da semente. Esse processo é repetido até que não restem
mais pontos com propriedade alterada na componente conectada (ou
região conectada).
nobjects=0;
Inicia a contagem de objetos (inicialmente, zero objetos estão
presentes)
for(int i=0; i<height; i++){
for(int j=0; j<width; j++){
if(image.at<uchar>(i,j) == 255){
nobjects++;
p.x=j;
p.y=i;
cv::floodFill(image,p,nobjects);
}
}
}
A contagem funciona percorrendo as linhas e colunas da matriz image
em busca de elementos com tom de cinza igual a 255 (pixel de
objeto). Quando encontrado, incrementa-se o contador de objeto e
executa-se o algoritmo floodfill na imagem utilizando o pixel
encontrado como semente. Observe que a região à qual o pixel pertence
será rotulada com tom de cinza igual ao número de contagem de objetos
atual.
O processo continua até que toda a imagem tenha sido rotulada.
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
Finalmente, a imagem image é mostrada (já completamente rotulada) e
então gravada no arquivo labeling.png. Uma das linhas com o comando
imshow é usada apenas para mostrar a imagem com um pouco de realce
(para fins de melhor visualização). Como esse efeito funciona será
discutido mais adiante.
5.2. Exercícios
-
Observando-se o programa labeling.cpp como
exemplo, é possível verificar que caso existam mais de 255 objetos
na cena, o processo de rotulação poderá ficar
comprometido. Identifique a situação em que isso ocorre e proponha
uma solução para este problema.
-
Aprimore o algoritmo de contagem apresentado para identificar
regiões com ou sem buracos internos que existam na cena. Assuma que
objetos com mais de um buraco podem existir. Inclua suporte no seu
algoritmo para não contar bolhas que tocam as bordas da imagem. Não
se pode presumir, a priori, que elas tenham buracos ou não.
6. Manipulação de histogramas
O objetivo dessa lição é mostrar como tratar histogramas de imagens usando
OpenCV. Histogramas são ferramentas interessantes para avaliar
características de uma imagem ou de atributos que dela são extraídos.
Um histograma é uma contagem de dados onde se organiza as ocorrências
por faixas de valores predefinidos. Em se tratando de imagens digitais
em tons de cinza, por exemplo, costuma-se associar um histograma com a
contagem de ocorrências de cada um dos possíveis tons em uma imagem. A
grosso modo, o histograma oferece uma estimativa da probabilidade de
ocorrência dos tons de cinza na imagem.
Exemplos típicos do uso de histogramas podem ser encontrados na
segmentação automática de imagens, detecção de movimento e
granulometria.
Além disso, a lição deverá explorar o uso dos recursos de captura de
vídeo disponíveis no OpenCV para lidar com câmeras conectadas ao
sistema.
O exemplo da Listagem Histograma mostra o processo de capturar
imagens de uma webcam instalada no computador, calcular os
histogramas das componentes de cor das imagens e desenhá-los no canto
superior esquerdo da imagem capturada.
Listagem 8. histogram.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv){
cv::Mat image;
int width, height;
cv::VideoCapture cap;
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
float range[] = {0, 255};
const float *histrange = { range };
bool uniform = true;
bool acummulate = false;
int key;
cap.open(0);
if(!cap.isOpened()){
std::cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura = " << width << std::endl;
std::cout << "altura = " << height << std::endl;
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
while(1){
cap >> image;
cv::split (image, planes);
cv::calcHist(&planes[0], 1, 0, cv::Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, cv::Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, cv::Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histG, histG, 0, histImgG.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histB, histB, 0, histImgB.rows, cv::NORM_MINMAX, -1, cv::Mat());
histImgR.setTo(cv::Scalar(0));
histImgG.setTo(cv::Scalar(0));
histImgB.setTo(cv::Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
cv::line(histImgG,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
cv::line(histImgB,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
cv::imshow("image", image);
key = cv::waitKey(30);
if(key == 27) break;
}
return 0;
}
Para compilar e executar o programa
histogram.cpp, salve-o juntamente com o arquivo
Makefile em um diretório e execute a seguinte
seqüência de comandos:
$ make histogram
$ ./histogram
A saída do programa histogram é mostrado na Figura 17
Figura 17. Saída do programa histogram
6.1. Descrição do programa histogram.cpp
cv::VideoCapture cap;
Fontes de captura de vídeo são acessadas no OpenCV através da classe
VideoCapture. Com ela, o usuário pode abrir um fluxo de vídeo
oriundo de um arquivo de vídeo, sequência de imagens ou de um
dispositivo de captura. Neste último caso, os dispositivos são
identificados por um índice que inicia em 0.
As imagens capturadas nesse exemplo serão extraídas de um fluxo de
vídeo que será conectado ao objeto cap.
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
O cálculo do histograma será realizado para cada uma das componentes
de cor de forma independente. Logo, a separação das componentes em
matrizes independentes será feita no vetor de matrizes
planes. Assim, planes[0], planes[1] e planes[2] armazenarão as
componentes de cor Vermelho, Verde e Azul, respectivamente.
As três matrizes histR, histG e histB guardarão os histogramas
de suas respectivas componentes de cor.
A variável nbins define o tamanho do vetor utilizado para armazenar
os histogramas. O tamanho do histograma não precisa ser
necessariamente o mesmo do ton de cinza máximo previsto para uma
componente de cor (ex: 256 para imagens RGB). É possível especificar a
quantidade de faixas (ou bins) que serão usadas para quantificar
as ocorrências dos tons.
No exemplo, usa-se um total de 64 faixas para um tom de cinza máximo
igual a 255. No cálculo, portanto, as ocorrências de tom de cinza na
faixa \$[0,3\$] serão contabilizadas no primeiro elemento do array
com o histograma; as ocorrências na faixa \$[4,7\$] contarão no
segundo elemento do histograma, e assim por diante.
float range[] = {0, 256};
const float *histrange = { range };
É preparada na variável histrange a faixa de valores (mínimo e
máximo) presentes na imagem cujo histograma será calculado. Essa
variável, da forma como é definida, é usada pela função de cálculo de
histograma.
bool uniform = true;
bool acummulate = false;
cap.open(0);
if(!cap.isOpened()){
cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
Abre-se a conexão com o primeiro dispositivo de captura de vídeo
disponível. Os dispositivos são identificados em sequência. Logo, se
um sistema dispõe de duas câmeras, por exemplo, a primeira será
associada ao identificador "0" e a segunda ao identificador "1".
Uma vez chamado o método open(), verifica-se se o dispositivo de
captura está devidamente conectado para proceder com o restante das
tarefas.
Neste exemplo, usamos o método set() para atribuir um tamanho aos quadros capturados pela câmera. A escolha foi feita de modo a fixar um tamanho altura x largura igual a 640x480 pixels. Contudo, é importante observar que as resoluções suportadas são dependentes do dispositivo usado para a captura dos quadros, de sorte que essas duas linhas poderão não surtir efeito em alguns casos.
Finalmente, lê-se a largura (width) e altura(height) dos quadros
que serão disponíveis pelo dispositivo. A classe VideoCapture possui
diversos métodos para ajustar os parâmetros de captura para o
dispositivo conectado. Entretanto, na versão do OpenCV em que foram
feitos os testes aqui descritos, alguns podem não funcionar
corretamente dependendo do tipo de dispositivo utilizado.
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
Define-se a largura e altura das imagens que serão usadas para
desenhar os histogramas de cada uma das componentes de cor. Note que a
altura da imagem é igual à metade da largura para fins de exibição. As
imagens são criadas com o tipo CV_8UC3, ou seja, com 8 bits por
pixel, com tipo de dados unsigned char contendo 3 canais de cor. A
cor, nesse caso, servirá apenas para que o histograma seja desenhado
na cor respectiva de sua componente.
cap >> image;
cv::split (image, planes);
Em um loop infinito, as imagens são capturadas, quadro a quadro, do
dispositivo de entrada conectado e armazenadas no objeto
image. Dispositivos de captura normalmente disponibilizam imagens
com suporte a cor, ou seja, cada matriz possui normalmente três planos
de cor. Logo, os histogramas deverão ser calculados para cada um
desses planos, de modo que a função split() faz a separação adequada
para que se proceda com o cálculo.
Histogramas hiperdimensionais que contabilizam as ocorrências das
combinações R,G e B dos pixels de uma imagem são possíveis de serem
calculados. Entretanto, normalmente são usadas matrizes esparças para
isso. Considerando imagens com 8 bits por pixel para cada plano de
cor, seria necessário uma matriz com 256 x 256 x 256 elementos para
guardar o histograma até mesmo de uma imagem pequena. Esse processo é
dispendioso e, normalmente, não possui muita utilidade.
Na análise de histograma, portanto, geralmente se avalia cada
componente de cor de forma independente.
cv::calcHist(&planes[0], 1, 0, Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
Os histogramas são então calculados para cada uma das componentes
de cor. A função calcHist() do OpenCV recebe, na sequência, os
seguintes argumentos:
-
Uma referência para imagem que se deseja processar;
-
A quantidade de imagens para se calcular o histograma (uma, neste
caso);
-
Um ponteiro para o array de canais das imagens. Para apenas um
canal, o endereço 0 deve ser repassado;
-
Uma máscara opcional marcando a região onde se deseja calcular o
histograma. Considerando a imagem inteira, fornece-se uma matriz
vazia;
-
O array que irá armazenar o histograma;
-
A dimensionalidade do histograma (no exemplo, existe apenas uma
dimensão);
-
O endereço da variável que armazena a quantidade de divisões; e
-
Variáveis informando se o histograma é uniforme (divisões de tamanho
igual) ou acumulado. Caso não seja uniforme, a variável histrange
deverá passar uma lista com os limites superiores de cada faixa.
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histG, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histB, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
Cada histograma é normalizado em uma faixa de valores que vai de 0
até a quantidade de linhas da imagem onde este será desenhado. A
normalização é feita linearmente entre os valores máximo e mínimo
encontrados na componente de cor.
histImgR.setTo(Scalar(0));
histImgG.setTo(Scalar(0));
histImgB.setTo(Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR, cv::Point(i, histh),
cv::Point(i, cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
line(histImgG, cv::Point(i, histh),
cv::Point(i, cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
line(histImgB, cv::Point(i, histh),
cv::Point(i, cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
As imagens com os desenhos dos histogramas são então
geradas. Inicialmente, todas são preenchidas com 0 (cor preta). Em
seguida, os histogramas são desenhados na forma de um gráfico de
barras usando a função line().
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
Finalmente, as imagens dos histogramas são copiadas, uma abaixo da
outra, para o canto superior esquerdo da imagem capturada na câmera.
6.2. Exercícios
-
Utilizando o programa exemplos/histogram.cpp como referência,
implemente um programa equalize.cpp. Este deverá, para cada
imagem capturada, realizar a equalização do histogram antes de
exibir a imagem. Teste sua implementação apontando a câmera para
ambientes com iluminações variadas e observando o efeito
gerado. Assuma que as imagens processadas serão em tons de cinza.
-
Utilizando o programa exemplos/histogram.cpp como referência,
implemente um programa motiondetector.cpp. Este deverá
continuamente calcular o histograma da imagem (apenas uma componente
de cor é suficiente) e compará-lo com o último histograma
calculado. Quando a diferença entre estes ultrapassar um limiar
pré-estabelecido, ative um alarme. Utilize uma função de comparação
que julgar conveniente.
7. Filtragem no domínio espacial I
A convolução é um processo pelo qual duas funções se combinam para
formar uma terceira função no domínio espacial. Tal processo resulta
do deslocamento de uma função sobre a outra e do cálculo de uma
combinação linear entre ambas em cada ponto do deslocamento.
Em se tratando de uma imagem digital, a convolução é chamada de
convolução digital. Sua principal aplicação é na filtragem de sinais,
permitindo que características de uma dada imagem sejam alteradas
conforme o tipo de efeito que se deseja impor.
A convolução discreta entre duas imagens pode ser definida como
\$h(x,y) = f(x,y)*g(x,y) = \frac{1}{MN}
\sum_{m=0}^{M-1}\sum_{n=0}^{N-1}f(m,n) g(x-m, y-n)\$
As funções \$f(x,y)\$ e \$g(x,y)\$ normalmente estão associadas
à imagem a ser filtrada e ao filtro digital associado.
Existem dois tipos de convolução: a 'convolução linear' e a
'convolução circular'. Na primeira, assume-se que os sinais
\$f(x,y)\$ e \$g(x,y)\$ existem em duas regiões com M e N
amostras consecutivas, respectivamente, sendo zero fora desssas
regiões. A região resultante da convolução terá suporte de tamanho
\$M+N-1\$. Fora desta, o resultado da convolução será nulo. Na
segunda, assume-se que as sequências \$f(x,y)\$ e \$g(x,y)\$ são
periódicas e com um mesmo período \$M=N\$. O resultado da
convolução, \$h(x,y)\$ possuirá também o mesmo período \$M\$.
Costuma-se simplificar essa equação e calcular os tons de cinza da
imagem filtrada realizando o produto entre os coeficientes de uma
pequena matriz comumente denominada 'máscara' e as intensidades dos
pixels sobre uma posição específica na imagem.
As máscaras normalmente possuem dimensões de tamanho ímpar (\$3
\times 3\$ elementos , \$5 \times 5\$ elementos, \$7 \times 7\$
elementos, etc), dependendo da intensidade da filtragem que se deseja
realizar.
Considere uma imagem digital denotada por \$f(x,y)\$, uma matriz de
máscara denotada por \$w(s,t)\$ e uma image filtrada denotada por
\$g(x,y)\$. Para uma máscara de tamanho \$3 \times 3\$
elementos, o processo de filtragem no domínio espacial é ilustrado na
Figura 18.
Figura 18. Filtragem espacial
No processo, a imagem da máscara é deslocada (pixel a pixel) sobre a
imagem a ser filtrada. Para cada deslocamento, calcula-se o somatório
do produto entre os valores dos elementos da máscara e os tons de
cinza dos pixels que esta sobrepõe e atribui-se o resultado ao pixel
respectivo na imagem filtrada.
Muitos efeitos de filtragem são possíveis de se obter modificando os
valores da imagem da máscara: borramento, aguçamento e detecção de
bordas são os principais deles.
O programa de referência utilizado para essa tarefa,
filtroespacial.cpp, é mostrado na
Listagem Filtroespacial.
Listagem 9. filtroespacial.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
void printmask(cv::Mat &m) {
for (int i = 0; i < m.size().height; i++) {
for (int j = 0; j < m.size().width; j++) {
std::cout << m.at<float>(i, j) << ",";
}
std::cout << "\n";
}
}
int main(int, char **) {
cv::VideoCapture cap; // open the default camera
float media[] = {0.1111, 0.1111, 0.1111, 0.1111, 0.1111,
0.1111, 0.1111, 0.1111, 0.1111};
float gauss[] = {0.0625, 0.125, 0.0625, 0.125, 0.25,
0.125, 0.0625, 0.125, 0.0625};
float horizontal[] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
float vertical[] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
float laplacian[] = {0, -1, 0, -1, 4, -1, 0, -1, 0};
float boost[] = {0, -1, 0, -1, 5.2, -1, 0, -1, 0};
cv::Mat frame, framegray, frame32f, frameFiltered;
cv::Mat mask(3, 3, CV_32F);
cv::Mat result;
double width, height;
int absolut;
char key;
cap.open(0);
if (!cap.isOpened()) // check if we succeeded
return -1;
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura=" << width << "\n";
;
std::cout << "altura =" << height << "\n";
;
std::cout << "fps =" << cap.get(cv::CAP_PROP_FPS) << "\n";
std::cout << "format =" << cap.get(cv::CAP_PROP_FORMAT) << "\n";
cv::namedWindow("filtroespacial", cv::WINDOW_NORMAL);
cv::namedWindow("original", cv::WINDOW_NORMAL);
mask = cv::Mat(3, 3, CV_32F, media);
absolut = 1; // calcs abs of the image
for (;;) {
cap >> frame; // get a new frame from camera
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
cv::imshow("original", framegray);
framegray.convertTo(frame32f, CV_32F);
cv::filter2D(frame32f, frameFiltered, frame32f.depth(),
mask,
cv::Point(1, 1), 0);
if (absolut) {
frameFiltered = cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
cv::imshow("filtroespacial", result);
key = (char)cv::waitKey(10);
if (key == 27) break; // esc pressed!
switch (key) {
case 'a':
absolut = !absolut;
break;
case 'm':
mask = cv::Mat(3, 3, CV_32F, media);
printmask(mask);
break;
case 'g':
mask = cv::Mat(3, 3, CV_32F, gauss);
printmask(mask);
break;
case 'h':
mask = cv::Mat(3, 3, CV_32F, horizontal);
printmask(mask);
break;
case 'v':
mask = cv::Mat(3, 3, CV_32F, vertical);
printmask(mask);
break;
case 'l':
mask = cv::Mat(3, 3, CV_32F, laplacian);
printmask(mask);
break;
case 'b':
mask = cv::Mat(3, 3, CV_32F, boost);
break;
default:
break;
}
}
return 0;
}
Para compilar e executar o programa filtroespacial.cpp,
salve-o juntamente com o arquivo Makefile
em um diretório e execute a seguinte seqüência de comandos:
$ make filtroespacial
$ ./filtroespacial
A saída do programa filtroespacial apresentará duas janelas: uma com
a imagem original capturada e outra com o resultado da filtragem. O
filtro inicial escolhido no exemplo é o da média.
7.1. Descrição do programa filtroespacial.cpp
float media[] = {0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111};
float gauss[] = {0.0625,0.125,0.0625,
0.125,0.25,0.125,
0.0625,0.125,0.0625};
float horizontal[]={-1,0,1,
-2,0,2,
-1,0,1};
float vertical[]={-1,-2,-1,
0,0,0,
1,2,1};
float laplacian[]={0,-1,0,
-1,4,-1,
0,-1,0};
float boost[]={0,-1,0,
-1,5.2,-1,
0,-1,0};
Os filtros usados no exemplo (determinados pelas matrizes de máscara)
são de tamanho \$3 \times 3\$ pixels. Cinco tipos de filtros são
testados: média, gaussiano, detector de bordas horizontais, detector de
bordas verticais e laplaciano. Os coeficientes de cada filtro são
armazenados em arrays unidimensionais que serão repassados ao
construtor da matriz do filtro.
mask = cv::Mat(3, 3, CV_32F, media);
Esse trecho de código mostra o procedimento padrão para construção da
matriz que será usada como máscara de filtragem. A variável mask
recebe uma matriz de tamanho \$3 \times 3\$ em ponto flutuante
(CV_32F) com valores iniciais iguais ao do array media que é
repassado. Repare que o tipo da matriz precisa ser estabelecido em
ponto flutuante, posto que as operações de cálculo contarão com a
presença de números fracionários.
cap >> frame;
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
Em loop infinito, imagens coloridas são capturadas constantemente na
matriz cap e convertidas em equivalentes em tons de cinza usando a
função cvtColor(). A imagem então é invertida horizontalmente com a
função flip(). A inversão é feita apenas para fins de tornar a
interação com o programa exemplo semelhante à de um espelho.
framegray.convertTo(frame32f, CV_32F);
filter2D(frame32f, frameFiltered, frame32f.depth(), mask, cv::Point(1,1), 0);
Este trecho é responsável pelo cálculo da filtragem espacial. Cada
imagem em tom de cinza armazenada na variável framegray é convertida
para outra equivalente com representação em ponto flutuante -
frame32f. A conversão é necessária devido aos tipos de operação que
serão realizados pela função filter2D(). Observe apenas que OpenCV
replica os pixels na borda ('ao invés de preencher de zeros') durante
o processo de filtragem.
A função filter2d() recebe então a matriz da imagem em ponto
flutuante - frame32f - e produz a matriz frameFiltered, de acordo
com o tipo do elemento da matriz de entrada - neste caso, CV_32F (ou
float). O objeto Point(1,1) que é repassado como próximo argumento
identifica a origem do sistema de coordenadas atribuído para a
máscara que, neste caso, é o ponto central da matriz.
if(absolut){
frameFiltered=cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
Caso a opção de módulo esteja selecionada, o cálculo é então
procedido. A imagem filtrada é então convertida para tons de cinza
para posterior exibição na tela.
O restante do código trata apenas da adaptação da matriz mask
conforme o filtro escolhido pelo usuário para ser aplicado à imagem
capturada.
7.2. Exercícios
-
Utilizando o programa exemplos/filtroespacial.cpp como
referência, implemente um programa laplgauss.cpp. O programa
deverá acrescentar mais uma funcionalidade ao exemplo fornecido,
permitindo que seja calculado o laplaciano do gaussiano das imagens
capturadas. Compare o resultado desse filtro com a simples aplicação
do filtro laplaciano.
8. Filtragem no domínio espacial II
Este capítulo visa explorar um pouco mais do uso de filtragem espacial
aplicando seus princípios para simular uma técnica de fotografia denominada
tilt-shift.
A técnica fotográfica de tilt-shift envolve o uso de deslocamentos e
rotações entre a lente e o plano de projeção (onde fica filme
fotográfico ou o sensor da câmera) de modo a desfocar seletivamente
regiões do assunto.
O princípio básico dessa técnica é ilustrado na
Figura 19.
Figura 19. Princípio de funcionamento do tilt shift
Na lente normal, o plano de projeção é paralelo ao plano de foco com o
assunto que se deseja registrar. Quando a lente é submetida a uma
inclinação (tilt), o plano de foco forma um ângulo diferente de zero
com o plano de projeção, mudando assim a região que ficará em foco na
imagem registrada pela câmera. Se a lente for deslocada para cima ou
para baixo (shift), é possível também escolher seletivamente a
região que ficará em foco, complementando o uso da técnica.
A técnica de tilt-shift consegue criar belos efeitos fotográficos,
simulando miniaturas. O foco seletivo que a lente produz engana o olho
humano, dando a impressão que a imagem foi registrada de uma cena em
miniatura. Tomando a imagem usando ângulos e proporções adequadas do
assunto, dá para se produzir versões em minatura de cenas reais que
podem ser bastante convincentes.
Lentes que produzem esse efeito não são baratas quando comparadas a
lentes normais. Entrentanto, o efeito produzido por estas lentes pode
ser reproduzido usando técnicas simples de processamento digital de
imagens.
O princípio utilizado para simular a lente tilt-shift é combinar a
imagem original com sua versão filtrada com filtro passa-baixas, de
sorte a produzir nas proximidades da borda o efeito do borramento
enquanto se mantém na região central a imagem sem borramento.
Uma forma de combinar pode ser realizada com a função addWeighted() do
OpenCV. Ela opera calculando a combinação linear de duas imagens
\$f_0(x,y)\$ e \$f_1(x,y)\$ pela
equação \$g(x,y) = (1 - \alpha)f_0(x,y) + \alpha f_1(x,y)\$, para um
dado valor de \$\alpha\$ fornecido.
O programa de referência utilizado para exemplificar o uso da função sugerida,
addweighted.cpp, é mostrado na
Listagem Addweighted.
Listagem 10. addweighted.cpp
#include <iostream>
#include <cstdio>
#include <opencv2/opencv.hpp>
double alfa;
int alfa_slider = 0;
int alfa_slider_max = 100;
int top_slider = 0;
int top_slider_max = 100;
cv::Mat image1, image2, blended;
cv::Mat imageTop;
char TrackbarName[50];
void on_trackbar_blend(int, void*){
alfa = (double) alfa_slider/alfa_slider_max ;
cv::addWeighted(image1, 1-alfa, imageTop, alfa, 0.0, blended);
cv::imshow("addweighted", blended);
}
void on_trackbar_line(int, void*){
image1.copyTo(imageTop);
int limit = top_slider*255/100;
if(limit > 0){
cv::Mat tmp = image2(cv::Rect(0, 0, 256, limit));
tmp.copyTo(imageTop(cv::Rect(0, 0, 256, limit)));
}
on_trackbar_blend(alfa_slider,0);
}
int main(int argvc, char** argv){
image1 = cv::imread("blend1.jpg");
image2 = cv::imread("blend2.jpg");
image2.copyTo(imageTop);
cv::namedWindow("addweighted", 1);
std::sprintf( TrackbarName, "Alpha x %d", alfa_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&alfa_slider,
alfa_slider_max,
on_trackbar_blend );
on_trackbar_blend(alfa_slider, 0 );
std::sprintf( TrackbarName, "Scanline x %d", top_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&top_slider,
top_slider_max,
on_trackbar_line );
on_trackbar_line(top_slider, 0 );
cv::waitKey(0);
return 0;
}
Para compilar e executar o programa
addweighted.cpp, salve-o juntamente com
o arquivo Makefile em um diretório juntamente
com as imagens exemplos/blend1.jpg e exemplos/blend2.jpg
e execute a seguinte seqüência de comandos:
$ make addweighted
$ ./addweighted
A saída do programa addweighted apresentará uma janela com duas
barras de controle: uma que regula o valor de \$alpha\$ e outra que
indica a região que será copiada de uma das imagens de entrada na
imagem da composição.
Utilizando os recursos do exemplo, é possível conceber uma função de
ponderação para combinar a imagem original com sua versão borrada por
um filtro da média. Entretanto, o desfoque não deve alterar a região
central da imagem final para que o efeito do tiltshift funcione.
Tal processo pode ser modelado usando uma função que define a região
de desfoque ao longo do eixo vertical da imagem. Uma possível função
que modela esse efeito é dada por
\$\alpha (x) = \frac{1}{2} ( \tanh \frac{x-l1}{d}-tanh\frac{x-l2}{d} )\$
Onde \$l1\$ e \$l2\$ são as linhas cujo valor de
\$\alpha\$ assume valor em torno de 0.5, caso os dois valores
possuam uma distância adequada um do outro, e \$d\$
indica a força do decaimento da região totalmente oriunda da imagem
original para a região totalmente oriunda da imagem borrada.
Para valores \$l1 = -20 \$, \$l2 = 30\$, e
\$d = 6\$, por exemplo, a função de ponderação se
comportaria como ilustrado na Figura 20.
Figura 20. Exemplo de função de ponderação para tiltshift
Assumindo que \$\alpha(x)\$ pondere a imagem original (denotada por
stem \$f(x,y)\$) e \$1-\alpha(x)\$ pondere a imagem borrada
(denotada por \$bf(x,y)\$), a composição \$g(x,y) = \alpha(x)
f(x,y) + (1-\alpha(x)) bf(x,y)\$ produzirá o efeito de tiltshift
desejado.
O processo de ponderação pode ser realizado por intermédio da função
multiply() do OpenCV, destinada à multiplicação de matrizes
elemento-a-elemento. Cria-se a imagem que irá ponderar as linhas da
imagem original e seu negativo irá ponderar as linhas da imagem
borrada. A combinação linear dessas duas imagens fara o efeito
simulado de tiltshift. A Figura 21 ilustra
possíveis imagens que poderiam ser usadas para ponderação no
processo. A da esquerda ponderaria a imagem original e a da direita a
imagem borrada.
Figura 21. Exemplo de imagens geradas para ponderação no tiltshift
8.1. Exercícios
-
Utilizando o programa exemplos/addweighted.cpp como referência,
implemente um programa tiltshift.cpp. Três ajustes deverão ser
providos na tela da interface:
-
um ajuste para regular a altura da região central que entrará em foco;
-
um ajuste para regular a força de decaimento da região borrada;
-
um ajuste para regular a posição vertical do centro da região que
entrará em foco.
Finalizado o programa, a imagem produzida deverá ser salva em
arquivo.
-
Utilizando o programa exemplos/addweighted.cpp como referência,
implemente um programa tiltshiftvideo.cpp. Tal programa deverá ser
capaz de processar um arquivo de vídeo, produzir o efeito de
tilt-shift nos quadros presentes e escrever o resultado em outro
arquivo de vídeo. A ideia é criar um efeito de miniaturização de
cenas. Descarte quadros em uma taxa que julgar conveniente para
evidenciar o efeito de
stop motion, comum
em vídeos desse tipo.
Parte II: Processamento de Imagens no Domínio da Frequência
9. A Tranformada Discreta de Fourier
O objetivo desse capítulo é apresentar a Transformada Discreta de Fourier
bidimensional. A Transformada de Fourier é uma transformada capaz de expressar um sinal
contínuo como uma combinação de funções de base senoidais ponderadas por
coeficientes. Em se tratando de sinais discretos, como é o caso de imagens
digitais, a Transformada Discreta de Fourier (ou DFT) é a transformada utilizada.
Para uma imagem digital, a Transformada Discreta de Fourier é capaz de fornecer
uma representação alternativa dessa imagem no domínio da frequência,
evidenciando degradações que não são facilmente tratadas no domínio espacial.
Exemplos de problemas dessa natureza são as interferências periódicas nas
transmissões de sinais analógicos, ou repetição de padrões presentes em figuras
antigas ou fotos de jornais.
Um exemplo de fotografia corrompida por um padrão senoidal é mostrada na
Figura 22. Note que existe uma espécie de grade de pontos presentes nessa
imagem.
Figura 22. Exemplo de imagem corrompida por uma cortina de pontos
O espectro de magnitude da Transformada Discreta de Fourier da imagem da Figura 22 é
mostrada na Figura 23. Perceba que há um conjunto de manchas simétricas que
surgem longe dos eixos, destacando-se do restante do sinal transformado. São
essas contribuições as causadoras da grade de pontos e podem ser removidas com o
uso de filtros adequados.
Figura 23. Transformada Discreta de Fourier da imagem da Figura 22
Para realizar o cálculo da Transformada Discreta de Fourier, é necessário
realizar uma série de passos que envolvem a preparação da matriz complexa
que deve ser fornecida à função de cálculo da DFT.
Para ilustrar o uso da Transformada Discreta de Fourier, considere o
exemplo mostrado na Listagem 11.
Listagem 11. dftimage.cpp
#include <iostream>
#include <vector>
#include <opencv2/opencv.hpp>
void swapQuadrants(cv::Mat& image) {
cv::Mat tmp, A, B, C, D;
// se a imagem tiver tamanho impar, recorta a regiao para o maior
// tamanho par possivel (-2 = 1111...1110)
image = image(cv::Rect(0, 0, image.cols & -2, image.rows & -2));
int centerX = image.cols / 2;
int centerY = image.rows / 2;
// rearranja os quadrantes da transformada de Fourier de forma que
// a origem fique no centro da imagem
// A B -> D C
// C D B A
A = image(cv::Rect(0, 0, centerX, centerY));
B = image(cv::Rect(centerX, 0, centerX, centerY));
C = image(cv::Rect(0, centerY, centerX, centerY));
D = image(cv::Rect(centerX, centerY, centerX, centerY));
// swap quadrants (Top-Left with Bottom-Right)
A.copyTo(tmp);
D.copyTo(A);
tmp.copyTo(D);
// swap quadrant (Top-Right with Bottom-Left)
C.copyTo(tmp);
B.copyTo(C);
tmp.copyTo(B);
}
int main(int argc, char** argv) {
cv::Mat image, padded, complexImage;
std::vector<cv::Mat> planos;
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
// expande a imagem de entrada para o melhor tamanho no qual a DFT pode ser
// executada, preenchendo com zeros a lateral inferior direita.
int dft_M = cv::getOptimalDFTSize(image.rows);
int dft_N = cv::getOptimalDFTSize(image.cols);
cv::copyMakeBorder(image, padded, 0, dft_M - image.rows, 0, dft_N - image.cols, cv::BORDER_CONSTANT, cv::Scalar::all(0));
// prepara a matriz complexa para ser preenchida
// primeiro a parte real, contendo a imagem de entrada
planos.push_back(cv::Mat_<float>(padded));
// depois a parte imaginaria com valores nulos
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
// combina os planos em uma unica estrutura de dados complexa
cv::merge(planos, complexImage);
// calcula a DFT
cv::dft(complexImage, complexImage);
swapQuadrants(complexImage);
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
// some uma constante para evitar log(0)
// log(1 + sqrt(Re(DFT(image))^2 + Im(DFT(image))^2))
magn += cv::Scalar::all(1);
// calcula o logaritmo da magnitude para exibir
// com compressao de faixa dinamica
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
// exibe as imagens processadas
cv::imshow("Imagem", image);
cv::imshow("Espectro de magnitude", magn);
cv::imshow("Espectro de fase", fase);
cv::waitKey();
return EXIT_SUCCESS;
}
9.1. Descrição do programa dftimage.cpp
A operação de cálculo da Transformada Discreta de Fourier em OpenCV pode ser
realizada pelo seguinte conjunto de passos:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de
cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante,
para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro.
Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da
transformada.
-
Cálculo do espectro de magnitude e de fase da transformada.
-
Compressão de faixa dinâmica do espectro de magnitude para melhor visualização.
-
Exibição dos espectros de magnitude e fase da transformada.
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
A imagem a ser processada é lida do disco e armazenada na variável image em
formato de escala de cinza. Caso a imagem não seja lida com sucesso, o programa
finaliza, apresentando uma mensagem de erro. A conversão para escala de cinza é
necessária pois o programa foi desenvolvido para processar imagens em escala de
cinza.
dft_M = cv::getOptimalDFTSize(image.rows);
dft_N = cv::getOptimalDFTSize(image.cols);
A função getOptimalDFTSize() identifica os melhores valores com base
no tamanho fornecido para acelerar o processo de cálculo da DFT com
base em algum algoritmo otimizado. Segundo a documentação do OpenCV,
valores múltiplos de dois, três e cinco produzem resultados
melhores. Os valores de tamanho ideal para a quantidade de linhas e
colunas da imagem são armazenados nas variáveis dft_M e dft_N,
respectivamente.
cv::copyMakeBorder(image, padded, 0,
dft_M - image.rows, 0,
dft_N - image.cols,
cv::BORDER_CONSTANT, cv::Scalar::all(0));
A função copyMakeBorder() cria uma versão da imagem fornecida com
uma borda preenchida com zeros e ajustada ao tamanho ótimo para
cálculo da DFT, conforme indicado pelo uso da função
getOptimalDFTSize(). Para uma imagem image fornecida, a saída é
produzida na imagem padded. Caso a imagem fornecida já
possua dimensões apropriadas, a imagem de saída será igual à de
entrada.
planos.push_back(cv::Mat_<float>(padded));
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
cv::merge(planos, complexImage);
Esse trecho de código prepara a matriz complexa que será fornecida à função de
cálculo do dft. Ambas são enfileiradas em um vetor de matrizes e a função
merge() se encarrega de produzir a matriz complexa a partir das matrizes
presentes no vetor planos.
// calcula o dft
cv::dft(complexImage, complexImage);
O cálculo do DFT é realizado. Perceba que tanto a matriz de entrada
quanto a de saída passadas como parâmetro podem ser a
mesma.
// realiza a troca de quadrantes
swapQuadrants(complexImage);
Finalizado o cálculo da DFT, a função swapQuadrants() realiza a
troca de quadrantes. Para melhor visualização do espectro de magnitude da transformada, é necessário
que o sinal transformado seja deslocado de modo que a origem do sinal fique
posicionada no centro da imagem, como ilustra a Figura 24.
Figura 24. Deslocamento da imagem transformada
A operação de troca de quadrantes realizada pela função swapQuadrants(). Ela
recebe a referência para a matriz que contém a imagem transformada e
troca seus quadrantes. Caso a imagem possua tamanho ímpar, ela é
diminuída de tamanho em um pixel para que a troca dos quadrantes
seja feita usando tamanhos imagens de iguais. Normalmente, trata-se a
imagem que será submetida ao cálculo da DFT para que possua dimensões
de ordem par, de sorte que essa linha não deverá alterar o tamanho das
imagens usualmente fornecidas.
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
Terminada a troca de quadrantes, a imagem transformada é separada em suas
componentes real e imaginária com a função split(). As componentes são então
armazenadas na forma de matrizes no vetor planos.
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
A função cartToPolar() calcula o espectro de magnitude e de fase a partir das
componentes real e imaginária da imagem transformada. Esta função foi escolhida
especificamente para o exemplo porque já consegue obter os dois espectros ao
mesmo tempo. Entretanto, perceba que logo após a normalização do espectro de
fase, há uma chamada à função magnitude().
A função magnitude() calcula somente o espectro de magnitude a partir das
componentes real e imaginária e, caso o espectro de fase não seja necessário
para a análise do sinal transformado, esta é a função mais indicada para o
cálculo do espectro de magnitude.
magn += cv::Scalar::all(1);
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
Esse trecho de código serve para realizar a compressão de faixa dinâmica e
normalização do espectro de magnitude na faixa \$0,1\$ para fins de exibição.
A compressão de faixa dinâmica é logaritmica e, para evitar erros de cálculo nas
situações em que algum dos elementos da matriz magn seja igual a zero, é
somado um valor constante a todos os elementos da matriz igual a um.
9.2. Exercícios
-
Utilizando os programa exemplos/dftimage.cpp, calcule e apresente o espectro de magnitude da imagem Figura 7.
-
Compare o espectro de magnitude gerado para a figura Figura 7 com o valor teórico da transformada de Fourier da senóide.
-
Usando agora o filestorage.cpp, mostrado na Listagem 4 como referência, adapte o programa exemplos/dftimage.cpp para ler a imagem em ponto flutuante armazenada no arquivo YAML equivalente (ilustrado na Listagem 5).
-
Compare o novo espectro de magnitude gerado com o valor teórico da transformada de Fourier da senóide. O que mudou para que o espectro de magnitude gerado agora esteja mais próximo do valor teórico? Porque isso aconteceu?
10. Filtragem no Domínio da Frequência
O objetivo da filtragem no domínio da frequência é remover ruídos e distorções
geralmente de natureza periódica numa imagem. Neste capítulo, veremos como criar
um filtro de frequência e aplicá-lo a uma imagem utilizando a DFT. O filtro de
frequência é uma matriz que possui o mesmo tamanho da imagem e que é
multiplicada pela transformada de Fourier da imagem para filtrar eventuais
problemas que existam na imagem.
O processo de filtragem envolve uma sequência de passos cuja parte delas já foi
explorada em outra lição. Os passos para realizar o processo de filtragem são:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de
cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante,
para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro.
Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da
transformada.
-
Criação de um filtro de frequência.
-
Multiplicação do filtro de frequência pela imagem transformada.
-
Troca de quadrantes para que a origem da imagem transformada volte para o
canto superior esquerdo.`
-
Remoção do padding da imagem (caso necessário).
-
Visualização da imagem filtrada.
Nessa sequência de passos, o processo de filtragem inicial com a criação do
filtro \$H(u,v)\$, tal que a imagem filtrada é dada por \$G(u,v) = H(u,v)
\cdot F(u,v)\$, onde \$F(u,v)\$ é a transformada de Fourier da imagem de
entrada e \$G(u,v)\$ é a transformada de Fourier da imagem filtrada. Esse
produto de matrizes é realizado elemento a elemento, e é implementado no OpenCV
pela função mulSpectrums().
Para compilar e executar o programa dftfilter.cpp,
salve-o juntamente com o arquivo Makefile
e a imagem biel.png em um diretório e execute a seguinte
seqüência de comandos:
$ make dftfilter
$ ./dftfilter biel.png
A saída do programa dftfilter é mostrado na Figura 25.
Figura 25. Resultado do programa dftfilter
10.1. Descrição do programa dftfilter.cpp
O trecho de código a seguir mostra a chamada da
função de criação do filtro de frequência e a aplicação do filtro na imagem.
cv::Mat filter;
makeFilter(complexImage, filter);
cv::mulSpectrums(complexImage, filter, complexImage, 0);
A função makeFilter() é responsável por criar o filtro de frequência. Ela
recebe a imagem transformada e a matriz que será preenchida com o filtro de
frequência é retornada no segundo parâmetro, que é passado na forma de uma
referência.
void makeFilter(const cv::Mat &image, cv::Mat &filter){
cv::Mat_<float> filter2D(image.rows, image.cols);
int centerX = image.cols / 2;
int centerY = image.rows / 2;
int radius = 20;
for (int i = 0; i < image.rows; i++) {
for (int j = 0; j < image.cols; j++) {
if (pow(i - centerY, 2) + pow(j - centerX, 2) <= pow(radius, 2)) { (1)
filter2D.at<float>(i, j) = 1;
} else {
filter2D.at<float>(i, j) = 0;
}
}
}
cv::Mat planes[] = {cv::Mat_<float>(filter2D),
cv::Mat::zeros(filter2D.size(), CV_32F)}; (2)
cv::merge(planes, 2, filter); (3)
}
1
A função makefilter() cria um filtro ideal de tamanho igual ao da imagem. Do
centro da matriz até uma distância de 20 pixels, o valor filtro é igual a 1.
Fora desse raio, o valor do filtro é igual a 0. O tipo de dado usado para criar
a matriz é float, pois é o tipo de dado usado para armazenar os valores da DFT.
2
Para criar o filtro de frequência, é necessário criar uma matriz com dois
canais, um para a parte real e outro para a parte imaginária. Daí a criação do
vetor de matrizes planes[] e…
3
A chamada da função merge() para criar a matriz de dois canais.
// calcula a DFT inversa
swapQuadrants(complexImage);
cv::idft(complexImage, complexImage);
cv::split(complexImage, planos);
// recorta a imagem filtrada para o tamanho original
// selecionando a regiao de interesse (roi)
cv::Rect roi(0, 0, image.cols, image.rows);
cv::Mat result = planos[0](roi);
A última parte do código mostra como recuperar a imagem filtrada. A transformada
de Fourier inversa é calculada com a função idft(). A função split() divide
a imagem multicanal em duas matrizes, uma para a parte real (planos[0]) e
outra para a parte imaginária (planos[1]).
A imagem filtrada é obtida selecionando a região de interesse da imagem
correspondente ao tamanho original da imagem de entrada usando um objeto da
classe Rect para esse fim. A imagem filtrada é armazenada na variável result
e posteriormente normalizada para exibição.
10.2. Exercícios
-
Utilizando o programa exemplos/dftfilter.cpp como referência,
implemente o filtro homomórfico para melhorar imagens com iluminação
irregular. Crie uma cena mal iluminada e ajuste os parâmetros do
filtro homomórfico para corrigir a iluminação da melhor forma
possível. Assuma que a imagem fornecida é em tons de cinza.
Parte III: Segmentação de imagens
11. Detecção de bordas com o algoritmo de Canny
O detector de bordas de Canny é sabidamente reconhecido como um dos
mais rápidos e eficientes algoritmos para encontrar descontinuidades
em uma imagem. Ele produz como resultado uma imagem binária contendo
os pontos de borda obtidos a partir de uma imagem, para um conjunto de
parâmetros de configuração.
Em linhas gerais, o algoritmo de Canny procura descobrir bordas situadas
em máximos locais do gradiente de uma image, e pode ser sumarizado
pelos seguintes passos:
-
Convolução com o filtro Gaussiano, cálculo da magnitude e ângulo do
gradiente.
-
Afinação das cristas largas do gradiente.
-
Classificação dos pontos quanto às orientações Horizontal,
Vertical, \(+45^\text{o}\), e \(-45^\text{o}\)
(intervalos de \(\pm 22.5^\text{o}\)).
-
Para os vizinhos na orientação determinada para o pixel, verificar
os seus gradientes.
-
Supressão de não máximos: se o valor da magnitude do gradiente
\(M(x,y)\) for inferior a pelo menos um de seus vizinhos, faça
\(g_N(x,y)=0\); caso contrário, faça \(g_N(x,y) =
M(x,y)\). A imagem \(g_N(x,y)\) é a imagem com supressão.
-
Limiarização com histerese é usada para a quebra do contorno (borda
tracejada).
-
Dois limiares \(T_1\) e \(T_2\). \(T_1 > T_2\) são usados.
-
Se o pixel é tal que \(g_N(x,y) \ge T_1\), é assumido como ponto de borda forte.
-
Para os pixels restantes, aqueles em que \(g_N(x,y) \ge T_2\), são assumidos como ponto de borda fraco.
-
Para todos os vizinhos dos pontos de borda fraco, procurar nos seus
8-vizinhos se há algum ponto de borda forte. Caso haja, este é marcado
como parte da fronteira.
-
Sugestão de Canny: \(T_H/T_L = 3/1\), ou \(T_H/T_L =2/1\)
Um exemplo de aplicação desse algoritmo na imagem da
Figura 26 é mostrado na Figura 27. Observe que
as bordas encontradas são bem localizadas e geralmente possuem
espessura igual a 1.
Figura 26. Exemplo para o detector de Canny
Figura 27. Detecção de bordas usando o filtro de Canny
O programa que gerou essa imagem é mostrado na Listagem 12.
Listagem 12. canny.cpp
#include <iostream>
#include "opencv2/opencv.hpp"
int top_slider = 10;
int top_slider_max = 200;
char TrackbarName[50];
cv::Mat image, border;
void on_trackbar_canny(int, void*){
cv::Canny(image, border, top_slider, 3*top_slider);
cv::imshow("Canny", border);
}
int main(int argc, char**argv){
image= cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
sprintf( TrackbarName, "Threshold inferior", top_slider_max );
cv::namedWindow("Canny",1);
cv::createTrackbar( TrackbarName, "Canny",
&top_slider,
top_slider_max,
on_trackbar_canny );
on_trackbar_canny(top_slider, 0 );
cv::waitKey();
cv::imwrite("cannyborders.png", border);
return 0;
}
Para compilar e executar o programa
canny.cpp, salve-o juntamente com os arquivo
Makefile e a imagem
biel.png em um diretório e execute a seguinte
seqüência de comandos:
$ make canny
$ ./canny biel.png
O programa disponibilizará uma scrollbar que regula o valor do
limiar \(T_1\). O valor do limiar \(T_2\) é
determinado automaticamente usando a proporção \(T_1 = 3
T_1\). Ao ser finalizado - quando uma tecla é pressionada - o programa
escreve a imagem de bordas no arquivo de nome cannyborders.png.
Valores diferentes para o limiar escolhido produzem imagens de bordas
diferentes.
A função de destaque nesse programa exemplo é apenas a função
Canny().
cv::Canny(image, border, top_slider, 3*top_slider);
Os dois primeiros argumentos indicam a imagem a ser processada, a
matriz onde a imagem de bordas será escrita, e os limiares
\(T_1\) e \(T_2\), neste caso representado pelas
quantidades top_slider e 3*top_slider.
11.1. Canny e a arte com pontilhismo
O algoritmo de Canny de fato é útil para diversas aplicações em
processamento de imagens e visão artificial. Informações de bordas
podem ser usadas para melhorar algoritmos de segmentação automática ou
para encontrar objetos em cenas e pontos de interesse.
Entretanto, nesta lição, a proposta de uso do algoritmo é para
desenvolver arte digital. A ideia é usar uma imagem de referência para
criar uma nova imagem usando efeitos artísticos pontilhistas.
O pontilhismo é uma técnica de desenho impressionista onde o quadro é
pintado usando apenas pontos. Um dos artistas pioneiros nessa técnica
foi
George
Seurat
. Vários dos seus trabalhos podem ser vistos online no site
georgesseurat.org.
Simular no computador um efeito pontilhista não é muito
trabalhoso. Uma estratégia simples é utilizar uma imagem de referência
e criar uma outra imagem desenhada usando pequenos círculos. Em suma,
percorre-se a imagem de referência e para cada pixel, desenha-se um
círculo com a mesma cor na posição correspondente na imagem
pontilhista.
Efeitos pontilhistas interessantes podem ser criados com variantes
simples dessa técnica. Exemplo: pular sequências de pixels na imagem
de referência para dar a impressão de que os pontos estão separados na
tela - isso é bastante comum na arte pontilhista. Outro efeito
interessante é realizar deslocamentos aleatórios nos centros dos
círculos, para que a imagem gerada permaneca menos
artificial. Finalmente, é razoável percorrer a matriz de referência
usando uma sequência aleatória, principalmente quando a técnica
pontilhista realiza a sobreposição de círculos.
Um exemplo de imagem pontilhista é mostrada na Figura 28.
Figura 28. Imagem pontilhista
O programa que gerou essa imagem é mostrado na Listagem 13.
Listagem 13. pontilhismo.cpp
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <opencv2/opencv.hpp>
#include <vector>
#define STEP 5
#define JITTER 3
#define RAIO 3
int main(int argc, char** argv) {
std::vector<int> yrange;
std::vector<int> xrange;
cv::Mat image, frame, points;
int width, height, gray;
int x, y;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
std::srand(std::time(0));
if (image.empty()) {
std::cout << "Could not open or find the image" << std::endl;
return -1;
}
width = image.cols;
height = image.rows;
xrange.resize(height / STEP);
yrange.resize(width / STEP);
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for (uint i = 0; i < xrange.size(); i++) {
xrange[i] = xrange[i] * STEP + STEP / 2;
}
for (uint i = 0; i < yrange.size(); i++) {
yrange[i] = yrange[i] * STEP + STEP / 2;
}
points = cv::Mat(height, width, CV_8U, cv::Scalar(255));
std::random_shuffle(xrange.begin(), xrange.end());
for (auto i : xrange) {
std::random_shuffle(yrange.begin(), yrange.end());
for (auto j : yrange) {
x = i + std::rand() % (2 * JITTER) - JITTER + 1;
y = j + std::rand() % (2 * JITTER) - JITTER + 1;
gray = image.at<uchar>(x, y);
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
}
}
cv::imwrite("pontos.jpg", points);
return 0;
}
Para compilar e executar o programa
pontilhismo.cpp, salve-o juntamente com
o arquivo Makefile e a imagem
biel.png em um diretório e execute a seguinte
seqüência de comandos:
$ make pontilhismo
$ ./pontilhismo biel.png
11.2. Descrição do programa pontilhismo.cpp
O programa pontilhismo.cpp não introduz novas funcionalidades da
biblioteca de programação OpenCV. Entretanto, algumas classes da
STL, a biblioteca padrão
de gabaritos do C++ estão presentes no código para facilitar a criação
de alguns efeitos. Logo, é importante discorrer um pouco sobre seu uso
no exemplo.
std::vector<int> yrange;
std::vector<int> xrange;
Define-se dois arrays de índices que servirão para identificar
elementos da imagem de referência. Os tamanhos dos arrays xrange e
yrange são determinados como frações da altura e da largura da
imagem, respectivamente. Isso é feito para que na geração da imagem
pontilhista, apenas alguns pontos sejam amostrados na imagem de
referência, evitando sobrecarga visual.
A grandeza STEP define o passo usado para varrer a imagem de
referência. No exemplo, usamos STEP igual a 5 pixels, ou seja,
considerando as duas dimensões da imagem, apenas 1 em cada
\(5 \times 5 = 25\) pixels de uma janela é usado para criar um
círculo.
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for(uint i=0; i<xrange.size(); i++){
xrange[i]= xrange[i]*STEP+STEP/2;
}
for(uint i=0; i<yrange.size(); i++){
yrange[i]= yrange[i]*STEP+STEP/2;
}
Os arrays xrange e yrange são preenchidos com valores sequenciais
iniciando em 0 e, em seguida, esses valores recebem um ganho igual a
STEP e um deslocamento STEP/2, para que o processo de amostragem
na imagem de referência se dê no centro da janela.
std::random_shuffle(xrange.begin(), xrange.end());
A função random_shuffle() recebe como parâmetros 2 iteradores: uma
para o início do array e outro para o final. Como resultado, a função
embaralha aleatoriamente todos seus elementos. Se observado, esse
processo é feito uma vez para o array de índices das linhas -
xrange - e, para cada linha, embaralha-se o array de índices das
colunas - yrange.
Os loops descritos por for(auto i : xrange) e for(auto j : yrange)
são construções na especificação C++11 e servem para fazer as
variáveis i e j assumirem, a cada passada no loop, os valores dos
arrays xrange e yrange de forma consecutiva.
x = i+rand()%(2*JITTER)-JITTER+1;
y = j+rand()%(2*JITTER)-JITTER+1;
O valor das coordenadas do ponto cujo tom de cinza será amostrado na
imagem de referência é determinado pela posição do centro da janela
mais um deslocamento aleatório em ambas as direções. Esse deslocamento
é determinado pela grandeza JITTER (igual a 3 pixels).
Variações das grandezas STEP e JITTER podem ser modificadas para
uso em imagens de tamanhos diferentes.
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
A função circle() é usada para traçar um círculo de raio
especificado em um ponto determinado pelo usuário. O círculo é
desenhado usando preenchimento sólido e, dada a presença do parâmetro
cv::LINE_AA, este será desenhado usando técnicas de antialiasing. Assim,
o círculo terá bordas não serrilhadas, produzindo um efeito visual
agradável na imagem pontilhista.
11.3. Exercícios
-
Utilizando os programas exemplos/canny.cpp e
exemplos/pontilhismo.cpp como referência, implemente um
programa cannypoints.cpp. A idéia é usar as bordas produzidas pelo
algoritmo de Canny para melhorar a qualidade da imagem pontilhista
gerada. A forma como a informação de borda será usada é
livre. Entretanto, são apresentadas algumas sugestões de técnicas
que poderiam ser utilizadas:
-
Desenhar pontos grandes na imagem pontilhista básica;
-
Usar a posição dos pixels de borda encontrados pelo algoritmo de
Canny para desenhar pontos nos respectivos locais na imagem gerada.
-
Experimente ir aumentando os limiares do algoritmo de Canny e, para
cada novo par de limiares, desenhar círculos cada vez menores nas
posições encontradas. A Figura 29 foi
desenvolvida usando essa técnica.
-
Escolha uma imagem de seu gosto e aplique a técnica que você
desenvolveu.
-
Descreva no seu relatório detalhes do procedimento usado para criar
sua técnica pontilhista.
Figura 29. Pontilhismo aplicado à imagem Lena
12. Quantização vetorial com k-means
Algoritmos de quantização são um grupo de técnicas usadas para mapear os
dados presentes em um conjunto grande em um conjunto menor de
elementos. É normalmente usada para fins de compressão de
dados. Quando um grande conjunto de pontos (vetores) é dividido em
em grupos de tamanho menor, diz-se que tem uma quantização
vetorial, onde cada grupo é representado por um centróide.
Dos vários algoritmos de quantização vetorial que podem ser
encontrados na literatura, o k-means está entre os mais populares. É
um algoritmo simples que particiona o espaço N-dimensional em células
de Voronoi, onde cada célula é determinada por um centro. O conjunto
de todos os pontos no espaço cuja distância para um dado centro é
menor que para todos os outros centros define a célula.
O algoritmo k-means funciona conforme os seguintes passos:
-
Escolha \$k\$ como o número de classes para os vetores
\$\mathbf{x}_i\$ de \$N\$ amostras,
\$i=1,2,\cdots,N\$.
-
Escolha \$\mathbf{m}_1, \mathbf{m}_2,\cdots,\mathbf{m}_k\$ como
aproximações iniciais para os centros das classes.
-
Classifique cada amostra \$\mathbf{x}_i\$ usando, por exemplo,
um classificador de distância mínima (distância euclideana).
-
Recalcule as médias \$\mathbf{m}_j\$ usando o resultado do
passo anterior.
-
Se as novas médias são consistentes (não mudam consideravelmente),
finalize o algoritmo. Caso contrário, recalcule os centros e
refaça a classificação.
Algo que se percebe do algoritmo k-means é que cada execução leva a um
resultado diferente do resultado anterior. Embora o algoritmo
normalmente estabilize, algumas execuções podem criar aglomerações
melhores que outras. Logo, é comum executar o algoritmo algumas vezes
e verificar qual execução gera melhor compactação dos dados. Uma
das medidas de compactação - a usada pelo OpenCV - verifica a soma dos
quadrados das distâncias dos pontos da amostra para seus respectivos
centros.
O programa de referência utilizado para essa tarefa,
kmeans.cpp, é mostrado na Listagem
Kmeans.
Listagem 14. kmeans.cpp
#include <cstdlib>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
int nClusters = 8, nRodadas = 5;
cv::Mat rotulos, centros;
if (argc != 3) {
std::cout << "kmeans entrada.jpg saida.jpg\n";
exit(0);
}
cv::Mat img = cv::imread(argv[1], cv::IMREAD_COLOR);
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
for (int z = 0; z < 3; z++) {
samples.at<float>(y + x * img.rows, z) = img.at<cv::Vec3b>(y, x)[z];
}
}
}
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
cv::Mat rotulada(img.size(), img.type());
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
int indice = rotulos.at<int>(y + x * img.rows, 0);
rotulada.at<cv::Vec3b>(y, x)[0] = (uchar)centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y, x)[1] = (uchar)centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y, x)[2] = (uchar)centros.at<float>(indice, 2);
}
}
cv::imshow("kmeans", rotulada);
cv::imwrite(argv[2], rotulada);
cv::waitKey();
}
Para compilar e executar o programa
kmeans.cpp, salve-o juntamente com o arquivo
Makefile e a imagem
sushi.jpg em um diretório e execute a seguinte
seqüência de comandos:
$ make kmeans
$ ./kmeans sushi.jpg sushi-kmeans.jpg
A saída do programa kmeans é mostrado na Figura 30
Figura 30. Saída do programa kmeans
12.1. Descrição do programa kmeans.cpp
O programa kmeans opera sobre a imagem fornecida como primeiro
argumento de modo a reduzir a quantidade de cores presentes na mesma
para um total de 6 cores (que pode ser ajustada pela variável
nClusters).
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
Uma matriz de amostras é criada para armazenar todas as cores dos
pixels da imagem. É comum executar o k-means com uma amostra do espaço
de entrada, mas utilizou-se a totalidade dos pixels imagem nesse
exemplo.
A matriz samples possui um total de linhas igual ao total
de pixels da imagem fornecida e apenas três colunas. Cada coluna é
concebida para armazenar cada uma das componentes de cor (R, G e B)
dos pixels.
samples.at<float>(y + x*img.rows, z) = img.at<cv::Vec3b>(y,x)[z];
A cópia pixel a pixel, componente a componente de cor é realizada da
imagem de entrada para a matriz de amostras.
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
A matriz com as amostras samples deve conter em cada linha uma das amostras a ser processada pela função disponível pelo opencv. nClusters informa a quantidade de aglomerados que se deseja obter. A matriz rotulos é um objeto do
tipo Mat preenchido com elementos do tipo int, onde cada elemento
identifica a classe à qual pertence a amostra na matriz samples. No
exemplo, um máximo de até 10000 iterações ou tolerância de 0.0001
devem ser atingidos para finalizar o algoritmo. O algoritmo é repetido
por uma quantidade de vezes definida por nRodadas. A rodada que
produz a menor soma de distâncias dos pontos para seus respectivos
centros é escolhida como vencedora. Os centros do algoritmo são
inicializados usando o algoritmo proposto por
Arthur2007. Finalmente,
as coordenadas dos centros são guardadas na matriz centros.
É importante perceber que tanto a matriz de amostras quanto a matriz
com os centros é definida como float para realizar a execução do
algoritmo. As aproximações geradas por matrizes inteiras levariam a
resultados incorretos do k-means.
rotulada.at<cv::Vec3b>(y,x)[0] = (uchar) centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y,x)[1] = (uchar) centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y,x)[2] = (uchar) centros.at<float>(indice, 2);
Por fim, uma versão quantizada da imagem de entrada é composta usando
os centros obtidos na execução do k-means.
12.2. Exercícios
-
Utilizando o programa kmeans.cpp como exemplo prepare um programa exemplo onde a execução do código se dê usando o parâmetro nRodadas=1 e inciar os centros de forma aleatória usando o parâmetro KMEANS_RANDOM_CENTERS ao invés de KMEANS_PP_CENTERS. Realize 10 rodadas diferentes do algoritmo e compare as imagens produzidas. Explique porque elas podem diferir tanto.
Parte IV: Outras Transformadas Matemáticas
13. Filtragem de forma com morfologia matemática
A filtragem de forma é uma técnica de processamento de imagens que visa corrigir imperfeições relacionadas com a forma de objetos que compõem, como por exemplo pequenas regiões. Ela é realizada através de operações morfológicas que atuam sobre a forma de objetos na imagem, modificando a propriedade dos pixels conforme propriedades de uma vizinhança selecionada. Assim como na operação de convolução a máscara utilizada desempenha um papel fundamental no resultado do processo, na morfologia, o efeito da filtragem é controlada por um conjunto denominado elemento estruturante. O elemento estruturante normalmente é uma matriz binária que define a forma e o tamanho da vizinhança que será utilizada para a filtragem.
A Figura 31 mostra um exemplo típico de uma imagem corrompiada pelo ruído de forma. Perceba que a figura contém várias linhas que não são desejadas, tanto permeando a região de fundo escuro quanto a região branca que representa o objeto.
Figura 31. Figura com falhas de forma
A filtragem de forma pode ser utilizada para corrigir esse problema usando o programa morfologia.cpp, que é mostrado na Listagem 15.
Listagem 15. morfologia.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
cv::Mat image, erosao, dilatacao, abertura, fechamento, abertfecha;
cv::Mat str;
if (argc != 2) {
std::cout << "morfologia entrada saida\n";
}
image = cv::imread(argv[1], cv::IMREAD_UNCHANGED);
// image = cv::imread(argv[1], -1);
if(image.empty()) {
std::cout << "Erro ao carregar a imagem: " << argv[1] << std::endl;
return -1;
}
// elemento estruturante
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
// erosao
cv::erode(image, erosao, str);
// dilatacao
cv::dilate(image, dilatacao, str);
// abertura
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
// fechamento
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
// abertura -> fechamento
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
cv::Mat matArray[] = {erosao, dilatacao, abertura, fechamento, abertfecha};
cv::hconcat(matArray, 5, image);
cv::imshow("morfologia", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa
morfologia.cpp, salve-o juntamente com o arquivo
Makefile e a imagem
morfoobjetos.png em um diretório e execute a seguinte
seqüência de comandos:
$ make morfologia
$ ./morfologia morfoobjetos.png
A saída do programa morfologia é mostrado na Figura 32. Da esquerda para a direita são apresentadas as imagens resultantes das operações erosão, dilatação, abertura, fechamento e abertura seguida de fechamento, respectivamente.
Figura 32. Saída do programa morfologia
13.1. Descrição do programa morfologia.cpp
O programa morfologia.cpp é um exemplo de aplicação da filtragem de forma. Ele recebe como parâmetro de entrada uma imagem e aplica as operações morfológicas de erosão, dilatação, abertura, fechamento e abertura seguida de fechamento. O programa utiliza a biblioteca OpenCV para carregar a imagem e exibir os resultados.
O primeiro passo da filtragem é criar o elemento estruturante que irá modelar as operações de filtragem morfológica.
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
A função getStructuringElement() cria um elemento estruturante com a forma de um retângulo de tamanho \$3 \times 3\$, todos preenchidos com o valor 1 (o elemento é representado como um objeto do tipo MAT). Essa marcação 1s indica que o elemento estruturante irá atuar sobre todos os pixels da vizinhança. A função getStructuringElement também pode ser utilizada para criar elementos estruturantes com formas diferentes, como por exemplo um elemento estruturante com forma de cruz, elipse ou disco, preenchido dentro do retângulo que limita o tamanho do elemento.
cv::erode(image, erosao, str);
Realiza a erosão da imagem image pelo elemento estruturante str.
cv::dilate(image, dilatacao, str);
Realiza a dilatação da imagem image pelo elemento estruturante str.
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
Realiza a abertura da imagem image pelo elemento estruturante str. A abertura é uma operação morfológica que consiste na erosão seguida de dilatação. Perceba que, ao contrário da erosão e dilatação, não há uma função específica para realizar a abertura. Para realizar a abertura é necessário chamar a função morphologyEx() passando como parâmetro o valor MORPH_OPEN para o parâmetro op. A função morphologyEx() é uma função genérica que permite realizar algumas das operações morfológicas mais comuns, como erosão, dilatação, abertura, fechamento, top hat, black hat e a transformada hit-or-miss.
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
Realiza o fechamento da imagem image pelo elemento estruturante str.
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
Realiza a abertura seguida de fechamento da imagem image pelo elemento estruturante str.
13.2. Exercícios
-
Um sistema de captura de imagens precisa realizar o reconhecimento de carateres de um visor de segmentos para uma aplicação industrial. O visor mostra caracteres como estes apresentados na Figura 33.
Figura 33. Caracteres do visor
Ocorre que o software de reconhecimento de padrões apresenta dificuldades de reconhecer os dígitos em virtude da separação existente entre os segmentos do visor. Idealmente, o software deveria reconhecer os dígitos como na Figura 34.
Figura 34. Caracteres ideais para o reconhecimento
Usando o programa morfologia.cpp como referência, crie um programa que resolva o problema da pré-filtragem de forma para reconhecimento dos caracteres usando operações morfológicas. Você poderá usar as imagens digitos-1.png, digitos-2.png, digitos-3.png, digitos-4.png e digitos-5.png para testar seu programa. Cuidado para deixar o ponto decimal separado dos demais dígitos para evitar um reconhecimento errado do número no visor.
Para o desenvolvimento das aplicações e exercícios propostos, o OpenCV (Open Source Computer Vision Library: http://www.opencv.org) que é uma biblioteca (ou conjunto de bibliotecas) de código aberto disponível para algumas linguagens de programação que oferece uma grande variedade de possibilidades e ferramentas úteis para processamento de imagens entre outras áreas gráficas.
OBS: É importante salientar que a instalação e configuração do software utilizado para desenvolver aplicações com OpenCV não serão relatados, mas podem ser encontrados em conteúdos disponibilizados nos mecanismos de busca da internet.
1. Conceitos iniciais
Nesta página, iremos abordar sobre diversos conceitos ligados ao processamento de imagens no domínio espacial, tendo como foco as áreas de: manipulação de imagens e histogramas, serialização de dados, decomposição de imagens, preenchimento de regiões e aplicações de métodos de filtragem.
2. Manipulando pixels em uma imagem
2.1. Exercício regions.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int, char**) {
cv::Mat image = cv::imread("onca.jpg", cv::IMREAD_COLOR);
if (!image.data) {
std::cout << "Erro ao abrir a imagem!" << std::endl;
return -1;
}
// Máscara negativa
cv::Rect mascara(100, 100, 200, 200);
// Percorre os pixels da máscara da imagem invertendo os pixels
for (int y = 100; y < 300; y++) {
for (int x = 100; x < 300; x++) {
// Obtém o valor do pixel na posição (x, y)
cv::Vec3b pixel = image.at<cv::Vec3b>(y, x);
// Inverte cada canal do pixel
cv::Vec3b inversao;
inversao[0] = 255 - pixel[0]; // Canal azul
inversao[1] = 255 - pixel[1]; // Canal verde
inversao[2] = 255 - pixel[2]; // Canal vermelho
// Atribui o pixel invertido à imagem resultante
image.at<cv::Vec3b>(y, x) = inversao;
}
}
// Exibe a imagem resultante
cv::namedWindow("Imagem com Região Negativa", cv::WINDOW_AUTOSIZE);
cv::imshow("Imagem com Região Negativa", image);
cv::waitKey(0);
return 0;
}
2.2. Exercício trocaregioes.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>
# define M_PI 3.14159265358979323846
int SIDE = 400;
int PERIODOS = 4;
int main(int argc, char** argv) {
std::stringstream ss_img, ss_yml;
cv::Mat image;
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * PERIODOS * i / SIDE) + 128;
}
}
fs << "mat" << image;
fs.release();
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
fs.open(ss_yml.str(), cv::FileStorage::READ);
fs["mat"] >> image;
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
cv::imshow("image", image);
cv::waitKey();
return 0;
}
3. Serialização de dados em ponto flutuante via FileStorage
Nem todo tipo de imagem pode ser armazenado em arquivos com formatos comuns como
JPEG ou PNG. Esses formatos suportam apenas imagens convencionais, cujo tipo de
dado associado ao pixel normalmente é um unsigned char. Entretanto, em muitos
casos, é necessário armazenar imagens com dados de ponto flutuante (float ou
double), como por exemplo, imagens de alta dinâmica, imagens HDR, imagens de
profundidade, máscaras usadas em filtros digitais, funções de transferência
usadas para filtragem no domínio da frequência etc. Para isso, o OpenCV
disponibiliza a classe cv::FileStorage, que permite armazenar dados em
arquivos com formatos mais genéricos, como XML ou YAML. Essa classe é muito útil
para armazenar dados de forma estruturada, como por exemplo, dados de uma
imagem, como largura, altura, número de canais, tipo de dado, etc. Assim,
matrizes representadas em ponto flutuante podem ser guardadas para uso
posterior.
Nesta lição, será mostrado como armazenar e recuperar dados em ponto flutuante
em um arquivo codificado em YAML. Para isso, será criada uma matriz de pixels do
tipo float e armazenada em um arquivo YAML. Em seguida, será recuperada a
matriz do arquivo YAML, processada e exibida na tela.
Para criar a matriz de float e armazená-la em um arquivo, realize o download
do programa filestorage.cpp, mostrado na
Listagem 4.
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>
int SIDE = 256;
int PERIODOS = 8;
int main(int argc, char** argv) {
std::stringstream ss_img, ss_yml;
cv::Mat image;
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * PERIODOS * j / SIDE) + 128;
}
}
fs << "mat" << image;
fs.release();
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
fs.open(ss_yml.str(), cv::FileStorage::READ);
fs["mat"] >> image;
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
cv::imshow("image", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa filestorage.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make filestorage
$ ./filestorage
A saída do programa filestorage é mostrado na Figura 6
3.1. Descrição do programa filestorage.cpp
ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);
A primeira linha usa a classe stringstream para criar um string com o nome do arquivo de saída. O nome do arquivo é formado pela concatenação da string "senoide-" com o valor da constante SIDE e a extensão ".yml". Perceba que é possível alterar o arquivo de saída para se amoldar ao tamanho desejado para o lado da imagem. A segunda linha cria uma matriz de float de tamanho SIDE x SIDE e inicializa todos os elementos com o valor 0. O tipo CV_32FC1 representa um dado em OpenCV de 32 bits em ponto flutuante com apenas um canal (o equivalente ao tipo float). A terceira linha cria um objeto da classe FileStorage para armazenar dados em um arquivo. O primeiro parâmetro é o nome do arquivo de saída, o segundo parâmetro é o modo de abertura do arquivo. Neste caso, o modo de abertura é cv::FileStorage::WRITE, o que indica que o arquivo será aberto para escrita, permitindo a gravação da imagem gerada em ponto flutuante.
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) {
image.at<float>(i, j) = 127 * sin(2 * M_PI * FREQUENCIA * j / SIDE) + 128;
}
}
O laço aninhado percorre todos os elementos da matriz image e atribui a cada elemento um valor de brilho correspondente à amplitude da senóide naquele ponto. Perceba que na imagem gerada a senóide percorre um total de 8 períodos ao longo de cada linha, o que equivale exatamente ao valor da variável FREQUENCIA. O valor da senoide é multiplicado por 127 e somado a 128 para que o valor da senóide fique entre 0 e 255.
fs << "mat" << image;
fs.release();
O objeto fs recebe a serialização dos dados da matriz image associados com o identificador literal "mat". Durante o processo de deserialização, o identificador literal "mat" será usado para recuperar os dados da matriz image. O método release() fecha o arquivo de saída. As linhas mostradas na Listagem 5 são extraídas do início do arquivo senoide-256.yml que é gerado pelo programa filestorage.cpp. Perceba que o arquivo codificado em YAML é escrito em texto simples, composto por uma sequência de pares chave-valor, onde a chave é o identificador literal "mat" e o valor é a matriz de float serializada.
%YAML:1.0
---
mat: !!opencv-matrix
rows: 256
cols: 256
dt: f
data: [ 128., 1.52776474e+02, 1.76600800e+02, 1.98557419e+02,
2.17802567e+02, 2.33596634e+02, 2.45332703e+02, 2.52559738e+02,
255., 2.52559738e+02, 2.45332703e+02, 2.33596634e+02,
2.17802567e+02, 1.98557419e+02, 1.76600800e+02, 1.52776474e+02,
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);
A mesma imagem é também gravada no formato PNG, para que possa ser visualizada.
Para isso, é necessário converter a matriz de float para o tipo CV_8U, que
representa um dado em OpenCV de 8 bits sem sinal com apenas um canal (o
equivalente ao tipo unsigned char). O método normalize() é usado para
normalizar os valores da matriz de float para o intervalo \$0, 255\$. O
método convertTo() converte a matriz de float para o tipo CV_8U para que a
gravação no arquivo determinado se dê sem intercorrências. Perceba que a classe
stringstream foi novamente usada para proceder com a criação do nome do
arquivo. A figura Figura 7 mostra a imagem gerada pelo programa
3.2. Exercícios
-
Utilizando o programa filestorage.cpp como base, crie um programa que gere uma imagem de dimensões 256x256 pixels contendo uma senóide de 4 períodos com amplitude de 127 desenhada na horizontal, como aquela apresentada na Figura 6 . Grave a imagem no formato PNG e no formato YML. Compare os arquivos gerados, extraindo uma linha de cada imagem gravada e comparando a diferença entre elas. Trace um gráfico da diferença calculada ao longo da linha correspondente extraída nas imagens. O que você observa?
4. Decomposição de imagens em planos de bits
Com apenas 8 bits é possível representar cada componente de cor em uma imagem em uma faixa de variação de 0 a 255. Apesar ter apenas um byte de tamanho, essa quantidade permite enganar com maestria o olho humano e ainda possibilita uma gama de aproximadamente 16 milhões de tonalidades de cores para compor uma imagem.
Os bits mais significativos dos pixels de uma imagem guardam as informações mais importantes para a composição da cor, ao passo que menos significativos pouca informação detém para olhos medianos. Observe, por exemplo, a sequência de imagens na Figura 8. Ela apresenta os planos de bits da imagem biel.png, onde cada uma mostra valores iguais a 0 ou 255, ou seja, são imagens monocromáticas com um bit por pixel. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo. Perceba que nos planos de bits menos significativos pouca informação sobre a imagem é revelada, enquanto nos planos de bits mais significativos a imagem é revelada com mais detalhes.
A imagem seguinte foi composta manipulando os bits de cada pixel de forma que os N bits menos significativos de cada componente de cor fossem deixados com valores iguais a zero para seis valores distintos de N, variando de 0 (canto superior esquerdo) a 7 (canto inferior direito).
Perceba como ocorre a degradação das cores da imagem na medida em que os bits são descartados. Para a imagem do exemplo, a degradação começa a ser percebida com a perda de 3 ou 4 bits menos significativos. É justamente aí que entra a possibilidade de usar os bits menos significativos da figura para ocultar informação, posto que sua influência geralmente não é perceptível na imagem.
4.1. Esteganografia em imagens digitais
Esteganografia é uma área da criptologia que se ocupa de ocultar uma informação em outra, de sorte a tornar despercebida uma determinada mensagem. Ela pode ser feita no computador usando arquivos de texto, imagens ou vídeos, de modo que apenas o receptor que conhece como a ocultação foi realizada saiba como recuperar a informação inserida. Na esteganografia, usa-se o princípio da ocultação por obscuridade, onde pressupõe-se que apenas o remetente e o destinatário sabem como decifrar o segredo enviado.
Embora esteja um desuso pelos algoritmos modernos de criptografia, ainda é possível se divertir um pouco com isso, combinando esteganografia com imagens digitais. A ideia é esconder uma imagem secreta einformáticam outra (imagem portadora), mas sem alterar significativamente a aparência da portadora.
Descartando-se uma quantidade de bits menos significativos de cada pixel, suficiente para não perder a qualidade visual da imagem, pode-se usar posições dos bits perdidos para esconder informação. Dá-se a essa prática o nome de esteganografia de bits menos significativos, ou Least Significant Bit steganography.
A ideia é esconder a imagem biel.jpg na imagem sushi.jpg, de modo que a imagem resultante não apresente diferenças significativas em relação à imagem portadora.
Para isso, serão preservados os 5 bits mais significativos (MSB) dos pixels da imagem portadora e os 3 bits mais significativos da imagem escondida serão colocados no lugar dos 3 bits menos significativos (LSB) da imagem portadora, como ilustra a Figura 11. É importante observar que ambas as imagem escondida deve ter no máximo o tamanho da imagem portadora.
Na Listagem 6 que segue é mostrado como esconder o conteúdo de uma imagem em outra utilizando operadores de manipulação de bits com OpenCV.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char**argv) {
cv::Mat imagemPortadora, imagemEscondida, imagemFinal;
cv::Vec3b valPortadora, valEscondida, valFinal;
int nbits = 3;
imagemPortadora = cv::imread(argv[1], cv::IMREAD_COLOR);
imagemEscondida = cv::imread(argv[2], cv::IMREAD_COLOR);
if (imagemPortadora.empty() || imagemEscondida.empty()) {
std::cout << "imagem nao carregou corretamente" << std::endl;
return (-1);
}
if (imagemPortadora.rows != imagemEscondida.rows ||
imagemPortadora.cols != imagemEscondida.cols) {
std::cout << "imagens devem ter o mesmo tamanho" << std::endl;
return (-1);
}
imagemFinal = imagemPortadora.clone();
for (int i = 0; i < imagemPortadora.rows; i++) {
for (int j = 0; j < imagemPortadora.cols; j++) {
valPortadora = imagemPortadora.at<cv::Vec3b>(i, j);
valEscondida = imagemEscondida.at<cv::Vec3b>(i, j);
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valPortadora[1] = valPortadora[1] >> nbits << nbits;
valPortadora[2] = valPortadora[2] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valEscondida[1] = valEscondida[1] >> (8-nbits);
valEscondida[2] = valEscondida[2] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
valFinal[1] = valPortadora[1] | valEscondida[1];
valFinal[2] = valPortadora[2] | valEscondida[2];
imagemFinal.at<cv::Vec3b>(i, j) = valFinal;
}
}
imwrite("esteganografia.png", imagemFinal);
return 0;
}
Para compilar e executar o programa esteg-encode.cpp, salve-o juntamente com os arquivo Makefile e a imagens sushi.jpg biel.jpg em um diretório e execute a seguinte seqüência de comandos:
$ make esteg-encode
$ ./esteg-encode sushi.jpg biel.jpg
4.2. Descrição do programa
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
A primeira linha desse trecho faz com que os N bits menos significativos da imagem portadora sejam anulados, onde N é o número de bits que serão usados para esconder a imagem codificada. A segunda linha faz com que os N bits mais significativos da imagem codificada sejam deslocados à direita para a posição dos N bits menos significativos na variável. A terceira linha faz a combinação dos valores das duas imagens, de modo que os N bits menos significativos da imagem portadora sejam substituídos pelos N bits mais significativos da imagem codificada.
A imagem resultante da esteganografia será gravada no arquivo esteganografia.png. O resultado da esteganografia é mostrado na Figura 12. Observe que a imagem resultante não apresenta diferenças visuais significativas em relação à imagem portadora.
A decomposição em planos de bits da imagem resultante da esteganografia mostra que os bits menos significativos da imagem portadora foram substituídos pelos bits mais significativos da imagem codificada, conforme mostra a Figura 13. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo.
4.3. Exercícios
-
Usando o programa esteg-encode.cpp como referência para esteganografia, escreva um programa que recupere a imagem codificada de uma imagem resultante de esteganografia. Lembre-se que os bits menos significativos dos pixels da imagem fornecida deverão compor os bits mais significativos dos pixels da imagem recuperada. O programa deve receber como parâmetros de linha de comando o nome da imagem resultante da esteganografia. Teste a sua implementação com a imagem da Figura 14 (desafio-esteganografia.png).
5. Preenchendo regiões
Uma tarefa bastante comum em processamento de imagens e visão artificial é contar a quantidade de objetos presentes em uma cena.
Para contar os objetos é necessário identificar os aglomerados de pixels associados a cada um. Neste exemplo, assume-se que a imagem é do tipo binária, ou seja, cada pixel assume apenas dois valores - 0 ou 255 - indicando que o pixel pertence ao fundo da imagem ("0") ou a algum objeto presente ("255"). Assume-se também que cada aglomerado de pixels será interpretado como um objeto individual. Esse é o processo mais comum para operações de contagem de objetos em uma imagem.
Uma das maneiras de identificar as regiões de forma única é através de rotulação. A rotulação de regiões é o processo pelo qual regiões com características comuns recebem um identificador comum (rótulo).
Em geral, um algoritmo de rotulação de imagens binárias recebe como entrada uma imagem binária e fornece como saída uma imagem em tons de cinza, com as várias regiões representativas de objetos rotuladas com um tom de cinza diferente.
No exemplo dessa lição será mostrado como rotular uma imagem binária, utilizando o algoritmo floodfill (ou seedfill) para descobrir os aglomerados de pixels. A imagem usada para teste será a presente no arquivo bolhas.png mostrada na Figura Bolhas.
O programa de referência utilizado para essa tarefa, labeling.cpp, é mostrado na Listagem Labeling.
#include <iostream>
#include <opencv2/opencv.hpp>
using namespace cv;
int main(int argc, char** argv) {
cv::Mat image, realce;
int width, height;
int nobjects;
cv::Point p;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
if (!image.data) {
std::cout << "imagem nao carregou corretamente\n";
return (-1);
}
width = image.cols;
height = image.rows;
std::cout << width << "x" << height << std::endl;
p.x = 0;
p.y = 0;
// busca objetos presentes
nobjects = 0;
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
if (image.at<uchar>(i, j) == 255) {
// achou um objeto
nobjects++;
// para o floodfill as coordenadas
// x e y são trocadas.
p.x = j;
p.y = i;
// preenche o objeto com o contador
cv::floodFill(image, p, nobjects);
}
}
}
std::cout << "a figura tem " << nobjects << " bolhas\n";
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa labeling.cpp, salve-o juntamente com os arquivo Makefile e bolhas.png em um diretório e execute a seguinte seqüência de comandos:
$ make labeling
$ ./labeling bolhas.png
A saída do programa labeling é mostrado na Figura Labeling
5.1. Descrição do programa labeling.cpp
cv::Point p;
A estrutura Point define um ponto na segunda dimensão que permite
acesso às suas coordenadas x e y. Ele será usado no exemplo para
indicar a semente de preenchimento que é usada pelo algoritmo floodfill.
image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);
Independentemente do formato da imagem de entrada, ela será convertida para tons de cinza, uma vez que o exemplo assume essa condição.
p.x=0;
p.y=0;
Nesta fase tem início o processo de rotulação das várias regiões da imagem. Assumindo que os pixels do objeto possuem tom de cinza igual a 255, o algoritmo percorre toda a imagem, linha após linha, de cima a baixo, da esquerda para direita por pixels que tenham tom igual a 255.
Quando um elemento da matriz é encontrado com tom de cinza igual a 255, o algoritmo floodfill é executado utilizando as coordenadas desse ponto como semente.
A operação do algoritmo floodfill é bem simples: dado um ponto semente, o algoritmo sai procurando os 4- ou 8-vizinhos desse ponto (conforme configuração estabelecida) que possuem a mesma propriedade do ponto semente (geralmente o tom de cinza). Para cada ponto encontrado, muda-se sua propriedade para uma nova propriedade fornecida. Para cada ponto encontrado, também, realiza-se a busca de vizinhança para os seus 4- ou 8-vizinhos que contenham a mesma propriedade da semente. Esse processo é repetido até que não restem mais pontos com propriedade alterada na componente conectada (ou região conectada).
nobjects=0;
Inicia a contagem de objetos (inicialmente, zero objetos estão presentes)
for(int i=0; i<height; i++){
for(int j=0; j<width; j++){
if(image.at<uchar>(i,j) == 255){
nobjects++;
p.x=j;
p.y=i;
cv::floodFill(image,p,nobjects);
}
}
}
A contagem funciona percorrendo as linhas e colunas da matriz image
em busca de elementos com tom de cinza igual a 255 (pixel de
objeto). Quando encontrado, incrementa-se o contador de objeto e
executa-se o algoritmo floodfill na imagem utilizando o pixel
encontrado como semente. Observe que a região à qual o pixel pertence
será rotulada com tom de cinza igual ao número de contagem de objetos
atual.
O processo continua até que toda a imagem tenha sido rotulada.
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
Finalmente, a imagem image é mostrada (já completamente rotulada) e
então gravada no arquivo labeling.png. Uma das linhas com o comando
imshow é usada apenas para mostrar a imagem com um pouco de realce
(para fins de melhor visualização). Como esse efeito funciona será
discutido mais adiante.
5.2. Exercícios
-
Observando-se o programa labeling.cpp como exemplo, é possível verificar que caso existam mais de 255 objetos na cena, o processo de rotulação poderá ficar comprometido. Identifique a situação em que isso ocorre e proponha uma solução para este problema.
-
Aprimore o algoritmo de contagem apresentado para identificar regiões com ou sem buracos internos que existam na cena. Assuma que objetos com mais de um buraco podem existir. Inclua suporte no seu algoritmo para não contar bolhas que tocam as bordas da imagem. Não se pode presumir, a priori, que elas tenham buracos ou não.
6. Manipulação de histogramas
O objetivo dessa lição é mostrar como tratar histogramas de imagens usando OpenCV. Histogramas são ferramentas interessantes para avaliar características de uma imagem ou de atributos que dela são extraídos.
Um histograma é uma contagem de dados onde se organiza as ocorrências por faixas de valores predefinidos. Em se tratando de imagens digitais em tons de cinza, por exemplo, costuma-se associar um histograma com a contagem de ocorrências de cada um dos possíveis tons em uma imagem. A grosso modo, o histograma oferece uma estimativa da probabilidade de ocorrência dos tons de cinza na imagem.
Exemplos típicos do uso de histogramas podem ser encontrados na segmentação automática de imagens, detecção de movimento e granulometria.
Além disso, a lição deverá explorar o uso dos recursos de captura de vídeo disponíveis no OpenCV para lidar com câmeras conectadas ao sistema.
O exemplo da Listagem Histograma mostra o processo de capturar imagens de uma webcam instalada no computador, calcular os histogramas das componentes de cor das imagens e desenhá-los no canto superior esquerdo da imagem capturada.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv){
cv::Mat image;
int width, height;
cv::VideoCapture cap;
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
float range[] = {0, 255};
const float *histrange = { range };
bool uniform = true;
bool acummulate = false;
int key;
cap.open(0);
if(!cap.isOpened()){
std::cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura = " << width << std::endl;
std::cout << "altura = " << height << std::endl;
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
while(1){
cap >> image;
cv::split (image, planes);
cv::calcHist(&planes[0], 1, 0, cv::Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, cv::Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, cv::Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histG, histG, 0, histImgG.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histB, histB, 0, histImgB.rows, cv::NORM_MINMAX, -1, cv::Mat());
histImgR.setTo(cv::Scalar(0));
histImgG.setTo(cv::Scalar(0));
histImgB.setTo(cv::Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
cv::line(histImgG,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
cv::line(histImgB,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
cv::imshow("image", image);
key = cv::waitKey(30);
if(key == 27) break;
}
return 0;
}
Para compilar e executar o programa histogram.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make histogram
$ ./histogram
A saída do programa histogram é mostrado na Figura 17
6.1. Descrição do programa histogram.cpp
cv::VideoCapture cap;
Fontes de captura de vídeo são acessadas no OpenCV através da classe
VideoCapture. Com ela, o usuário pode abrir um fluxo de vídeo
oriundo de um arquivo de vídeo, sequência de imagens ou de um
dispositivo de captura. Neste último caso, os dispositivos são
identificados por um índice que inicia em 0.
As imagens capturadas nesse exemplo serão extraídas de um fluxo de
vídeo que será conectado ao objeto cap.
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
O cálculo do histograma será realizado para cada uma das componentes
de cor de forma independente. Logo, a separação das componentes em
matrizes independentes será feita no vetor de matrizes
planes. Assim, planes[0], planes[1] e planes[2] armazenarão as
componentes de cor Vermelho, Verde e Azul, respectivamente.
As três matrizes histR, histG e histB guardarão os histogramas
de suas respectivas componentes de cor.
A variável nbins define o tamanho do vetor utilizado para armazenar
os histogramas. O tamanho do histograma não precisa ser
necessariamente o mesmo do ton de cinza máximo previsto para uma
componente de cor (ex: 256 para imagens RGB). É possível especificar a
quantidade de faixas (ou bins) que serão usadas para quantificar
as ocorrências dos tons.
No exemplo, usa-se um total de 64 faixas para um tom de cinza máximo igual a 255. No cálculo, portanto, as ocorrências de tom de cinza na faixa \$[0,3\$] serão contabilizadas no primeiro elemento do array com o histograma; as ocorrências na faixa \$[4,7\$] contarão no segundo elemento do histograma, e assim por diante.
float range[] = {0, 256};
const float *histrange = { range };
É preparada na variável histrange a faixa de valores (mínimo e
máximo) presentes na imagem cujo histograma será calculado. Essa
variável, da forma como é definida, é usada pela função de cálculo de
histograma.
bool uniform = true;
bool acummulate = false;
cap.open(0);
if(!cap.isOpened()){
cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
Abre-se a conexão com o primeiro dispositivo de captura de vídeo disponível. Os dispositivos são identificados em sequência. Logo, se um sistema dispõe de duas câmeras, por exemplo, a primeira será associada ao identificador "0" e a segunda ao identificador "1".
Uma vez chamado o método open(), verifica-se se o dispositivo de
captura está devidamente conectado para proceder com o restante das
tarefas.
Neste exemplo, usamos o método set() para atribuir um tamanho aos quadros capturados pela câmera. A escolha foi feita de modo a fixar um tamanho altura x largura igual a 640x480 pixels. Contudo, é importante observar que as resoluções suportadas são dependentes do dispositivo usado para a captura dos quadros, de sorte que essas duas linhas poderão não surtir efeito em alguns casos.
Finalmente, lê-se a largura (width) e altura(height) dos quadros
que serão disponíveis pelo dispositivo. A classe VideoCapture possui
diversos métodos para ajustar os parâmetros de captura para o
dispositivo conectado. Entretanto, na versão do OpenCV em que foram
feitos os testes aqui descritos, alguns podem não funcionar
corretamente dependendo do tipo de dispositivo utilizado.
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
Define-se a largura e altura das imagens que serão usadas para
desenhar os histogramas de cada uma das componentes de cor. Note que a
altura da imagem é igual à metade da largura para fins de exibição. As
imagens são criadas com o tipo CV_8UC3, ou seja, com 8 bits por
pixel, com tipo de dados unsigned char contendo 3 canais de cor. A
cor, nesse caso, servirá apenas para que o histograma seja desenhado
na cor respectiva de sua componente.
cap >> image;
cv::split (image, planes);
Em um loop infinito, as imagens são capturadas, quadro a quadro, do
dispositivo de entrada conectado e armazenadas no objeto
image. Dispositivos de captura normalmente disponibilizam imagens
com suporte a cor, ou seja, cada matriz possui normalmente três planos
de cor. Logo, os histogramas deverão ser calculados para cada um
desses planos, de modo que a função split() faz a separação adequada
para que se proceda com o cálculo.
Histogramas hiperdimensionais que contabilizam as ocorrências das combinações R,G e B dos pixels de uma imagem são possíveis de serem calculados. Entretanto, normalmente são usadas matrizes esparças para isso. Considerando imagens com 8 bits por pixel para cada plano de cor, seria necessário uma matriz com 256 x 256 x 256 elementos para guardar o histograma até mesmo de uma imagem pequena. Esse processo é dispendioso e, normalmente, não possui muita utilidade.
Na análise de histograma, portanto, geralmente se avalia cada componente de cor de forma independente.
cv::calcHist(&planes[0], 1, 0, Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
Os histogramas são então calculados para cada uma das componentes
de cor. A função calcHist() do OpenCV recebe, na sequência, os
seguintes argumentos:
-
Uma referência para imagem que se deseja processar;
-
A quantidade de imagens para se calcular o histograma (uma, neste caso);
-
Um ponteiro para o array de canais das imagens. Para apenas um canal, o endereço
0deve ser repassado; -
Uma máscara opcional marcando a região onde se deseja calcular o histograma. Considerando a imagem inteira, fornece-se uma matriz vazia;
-
O array que irá armazenar o histograma;
-
A dimensionalidade do histograma (no exemplo, existe apenas uma dimensão);
-
O endereço da variável que armazena a quantidade de divisões; e
-
Variáveis informando se o histograma é uniforme (divisões de tamanho igual) ou acumulado. Caso não seja uniforme, a variável
histrangedeverá passar uma lista com os limites superiores de cada faixa.
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histG, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histB, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
Cada histograma é normalizado em uma faixa de valores que vai de 0
até a quantidade de linhas da imagem onde este será desenhado. A
normalização é feita linearmente entre os valores máximo e mínimo
encontrados na componente de cor.
histImgR.setTo(Scalar(0));
histImgG.setTo(Scalar(0));
histImgB.setTo(Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR, cv::Point(i, histh),
cv::Point(i, cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
line(histImgG, cv::Point(i, histh),
cv::Point(i, cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
line(histImgB, cv::Point(i, histh),
cv::Point(i, cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
As imagens com os desenhos dos histogramas são então
geradas. Inicialmente, todas são preenchidas com 0 (cor preta). Em
seguida, os histogramas são desenhados na forma de um gráfico de
barras usando a função line().
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
Finalmente, as imagens dos histogramas são copiadas, uma abaixo da outra, para o canto superior esquerdo da imagem capturada na câmera.
6.2. Exercícios
-
Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa
equalize.cpp. Este deverá, para cada imagem capturada, realizar a equalização do histogram antes de exibir a imagem. Teste sua implementação apontando a câmera para ambientes com iluminações variadas e observando o efeito gerado. Assuma que as imagens processadas serão em tons de cinza. -
Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa
motiondetector.cpp. Este deverá continuamente calcular o histograma da imagem (apenas uma componente de cor é suficiente) e compará-lo com o último histograma calculado. Quando a diferença entre estes ultrapassar um limiar pré-estabelecido, ative um alarme. Utilize uma função de comparação que julgar conveniente.
7. Filtragem no domínio espacial I
A convolução é um processo pelo qual duas funções se combinam para formar uma terceira função no domínio espacial. Tal processo resulta do deslocamento de uma função sobre a outra e do cálculo de uma combinação linear entre ambas em cada ponto do deslocamento.
Em se tratando de uma imagem digital, a convolução é chamada de convolução digital. Sua principal aplicação é na filtragem de sinais, permitindo que características de uma dada imagem sejam alteradas conforme o tipo de efeito que se deseja impor.
A convolução discreta entre duas imagens pode ser definida como
As funções \$f(x,y)\$ e \$g(x,y)\$ normalmente estão associadas à imagem a ser filtrada e ao filtro digital associado.
Existem dois tipos de convolução: a 'convolução linear' e a 'convolução circular'. Na primeira, assume-se que os sinais \$f(x,y)\$ e \$g(x,y)\$ existem em duas regiões com M e N amostras consecutivas, respectivamente, sendo zero fora desssas regiões. A região resultante da convolução terá suporte de tamanho \$M+N-1\$. Fora desta, o resultado da convolução será nulo. Na segunda, assume-se que as sequências \$f(x,y)\$ e \$g(x,y)\$ são periódicas e com um mesmo período \$M=N\$. O resultado da convolução, \$h(x,y)\$ possuirá também o mesmo período \$M\$.
Costuma-se simplificar essa equação e calcular os tons de cinza da imagem filtrada realizando o produto entre os coeficientes de uma pequena matriz comumente denominada 'máscara' e as intensidades dos pixels sobre uma posição específica na imagem.
As máscaras normalmente possuem dimensões de tamanho ímpar (\$3 \times 3\$ elementos , \$5 \times 5\$ elementos, \$7 \times 7\$ elementos, etc), dependendo da intensidade da filtragem que se deseja realizar.
Considere uma imagem digital denotada por \$f(x,y)\$, uma matriz de máscara denotada por \$w(s,t)\$ e uma image filtrada denotada por \$g(x,y)\$. Para uma máscara de tamanho \$3 \times 3\$ elementos, o processo de filtragem no domínio espacial é ilustrado na Figura 18.
No processo, a imagem da máscara é deslocada (pixel a pixel) sobre a imagem a ser filtrada. Para cada deslocamento, calcula-se o somatório do produto entre os valores dos elementos da máscara e os tons de cinza dos pixels que esta sobrepõe e atribui-se o resultado ao pixel respectivo na imagem filtrada.
Muitos efeitos de filtragem são possíveis de se obter modificando os valores da imagem da máscara: borramento, aguçamento e detecção de bordas são os principais deles.
O programa de referência utilizado para essa tarefa, filtroespacial.cpp, é mostrado na Listagem Filtroespacial.
#include <iostream>
#include <opencv2/opencv.hpp>
void printmask(cv::Mat &m) {
for (int i = 0; i < m.size().height; i++) {
for (int j = 0; j < m.size().width; j++) {
std::cout << m.at<float>(i, j) << ",";
}
std::cout << "\n";
}
}
int main(int, char **) {
cv::VideoCapture cap; // open the default camera
float media[] = {0.1111, 0.1111, 0.1111, 0.1111, 0.1111,
0.1111, 0.1111, 0.1111, 0.1111};
float gauss[] = {0.0625, 0.125, 0.0625, 0.125, 0.25,
0.125, 0.0625, 0.125, 0.0625};
float horizontal[] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
float vertical[] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
float laplacian[] = {0, -1, 0, -1, 4, -1, 0, -1, 0};
float boost[] = {0, -1, 0, -1, 5.2, -1, 0, -1, 0};
cv::Mat frame, framegray, frame32f, frameFiltered;
cv::Mat mask(3, 3, CV_32F);
cv::Mat result;
double width, height;
int absolut;
char key;
cap.open(0);
if (!cap.isOpened()) // check if we succeeded
return -1;
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura=" << width << "\n";
;
std::cout << "altura =" << height << "\n";
;
std::cout << "fps =" << cap.get(cv::CAP_PROP_FPS) << "\n";
std::cout << "format =" << cap.get(cv::CAP_PROP_FORMAT) << "\n";
cv::namedWindow("filtroespacial", cv::WINDOW_NORMAL);
cv::namedWindow("original", cv::WINDOW_NORMAL);
mask = cv::Mat(3, 3, CV_32F, media);
absolut = 1; // calcs abs of the image
for (;;) {
cap >> frame; // get a new frame from camera
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
cv::imshow("original", framegray);
framegray.convertTo(frame32f, CV_32F);
cv::filter2D(frame32f, frameFiltered, frame32f.depth(),
mask,
cv::Point(1, 1), 0);
if (absolut) {
frameFiltered = cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
cv::imshow("filtroespacial", result);
key = (char)cv::waitKey(10);
if (key == 27) break; // esc pressed!
switch (key) {
case 'a':
absolut = !absolut;
break;
case 'm':
mask = cv::Mat(3, 3, CV_32F, media);
printmask(mask);
break;
case 'g':
mask = cv::Mat(3, 3, CV_32F, gauss);
printmask(mask);
break;
case 'h':
mask = cv::Mat(3, 3, CV_32F, horizontal);
printmask(mask);
break;
case 'v':
mask = cv::Mat(3, 3, CV_32F, vertical);
printmask(mask);
break;
case 'l':
mask = cv::Mat(3, 3, CV_32F, laplacian);
printmask(mask);
break;
case 'b':
mask = cv::Mat(3, 3, CV_32F, boost);
break;
default:
break;
}
}
return 0;
}
Para compilar e executar o programa filtroespacial.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make filtroespacial
$ ./filtroespacial
A saída do programa filtroespacial apresentará duas janelas: uma com a imagem original capturada e outra com o resultado da filtragem. O filtro inicial escolhido no exemplo é o da média.
7.1. Descrição do programa filtroespacial.cpp
float media[] = {0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111};
float gauss[] = {0.0625,0.125,0.0625,
0.125,0.25,0.125,
0.0625,0.125,0.0625};
float horizontal[]={-1,0,1,
-2,0,2,
-1,0,1};
float vertical[]={-1,-2,-1,
0,0,0,
1,2,1};
float laplacian[]={0,-1,0,
-1,4,-1,
0,-1,0};
float boost[]={0,-1,0,
-1,5.2,-1,
0,-1,0};
Os filtros usados no exemplo (determinados pelas matrizes de máscara) são de tamanho \$3 \times 3\$ pixels. Cinco tipos de filtros são testados: média, gaussiano, detector de bordas horizontais, detector de bordas verticais e laplaciano. Os coeficientes de cada filtro são armazenados em arrays unidimensionais que serão repassados ao construtor da matriz do filtro.
mask = cv::Mat(3, 3, CV_32F, media);
Esse trecho de código mostra o procedimento padrão para construção da
matriz que será usada como máscara de filtragem. A variável mask
recebe uma matriz de tamanho \$3 \times 3\$ em ponto flutuante
(CV_32F) com valores iniciais iguais ao do array media que é
repassado. Repare que o tipo da matriz precisa ser estabelecido em
ponto flutuante, posto que as operações de cálculo contarão com a
presença de números fracionários.
cap >> frame;
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
Em loop infinito, imagens coloridas são capturadas constantemente na
matriz cap e convertidas em equivalentes em tons de cinza usando a
função cvtColor(). A imagem então é invertida horizontalmente com a
função flip(). A inversão é feita apenas para fins de tornar a
interação com o programa exemplo semelhante à de um espelho.
framegray.convertTo(frame32f, CV_32F);
filter2D(frame32f, frameFiltered, frame32f.depth(), mask, cv::Point(1,1), 0);
Este trecho é responsável pelo cálculo da filtragem espacial. Cada
imagem em tom de cinza armazenada na variável framegray é convertida
para outra equivalente com representação em ponto flutuante -
frame32f. A conversão é necessária devido aos tipos de operação que
serão realizados pela função filter2D(). Observe apenas que OpenCV
replica os pixels na borda ('ao invés de preencher de zeros') durante
o processo de filtragem.
A função filter2d() recebe então a matriz da imagem em ponto
flutuante - frame32f - e produz a matriz frameFiltered, de acordo
com o tipo do elemento da matriz de entrada - neste caso, CV_32F (ou
float). O objeto Point(1,1) que é repassado como próximo argumento
identifica a origem do sistema de coordenadas atribuído para a
máscara que, neste caso, é o ponto central da matriz.
if(absolut){
frameFiltered=cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
Caso a opção de módulo esteja selecionada, o cálculo é então procedido. A imagem filtrada é então convertida para tons de cinza para posterior exibição na tela.
O restante do código trata apenas da adaptação da matriz mask
conforme o filtro escolhido pelo usuário para ser aplicado à imagem
capturada.
7.2. Exercícios
-
Utilizando o programa exemplos/filtroespacial.cpp como referência, implemente um programa
laplgauss.cpp. O programa deverá acrescentar mais uma funcionalidade ao exemplo fornecido, permitindo que seja calculado o laplaciano do gaussiano das imagens capturadas. Compare o resultado desse filtro com a simples aplicação do filtro laplaciano.
8. Filtragem no domínio espacial II
Este capítulo visa explorar um pouco mais do uso de filtragem espacial aplicando seus princípios para simular uma técnica de fotografia denominada tilt-shift.
A técnica fotográfica de tilt-shift envolve o uso de deslocamentos e rotações entre a lente e o plano de projeção (onde fica filme fotográfico ou o sensor da câmera) de modo a desfocar seletivamente regiões do assunto.
O princípio básico dessa técnica é ilustrado na Figura 19.
Na lente normal, o plano de projeção é paralelo ao plano de foco com o assunto que se deseja registrar. Quando a lente é submetida a uma inclinação (tilt), o plano de foco forma um ângulo diferente de zero com o plano de projeção, mudando assim a região que ficará em foco na imagem registrada pela câmera. Se a lente for deslocada para cima ou para baixo (shift), é possível também escolher seletivamente a região que ficará em foco, complementando o uso da técnica.
A técnica de tilt-shift consegue criar belos efeitos fotográficos, simulando miniaturas. O foco seletivo que a lente produz engana o olho humano, dando a impressão que a imagem foi registrada de uma cena em miniatura. Tomando a imagem usando ângulos e proporções adequadas do assunto, dá para se produzir versões em minatura de cenas reais que podem ser bastante convincentes.
Lentes que produzem esse efeito não são baratas quando comparadas a lentes normais. Entrentanto, o efeito produzido por estas lentes pode ser reproduzido usando técnicas simples de processamento digital de imagens.
O princípio utilizado para simular a lente tilt-shift é combinar a imagem original com sua versão filtrada com filtro passa-baixas, de sorte a produzir nas proximidades da borda o efeito do borramento enquanto se mantém na região central a imagem sem borramento.
Uma forma de combinar pode ser realizada com a função addWeighted() do
OpenCV. Ela opera calculando a combinação linear de duas imagens
\$f_0(x,y)\$ e \$f_1(x,y)\$ pela
equação \$g(x,y) = (1 - \alpha)f_0(x,y) + \alpha f_1(x,y)\$, para um
dado valor de \$\alpha\$ fornecido.
O programa de referência utilizado para exemplificar o uso da função sugerida, addweighted.cpp, é mostrado na Listagem Addweighted.
#include <iostream>
#include <cstdio>
#include <opencv2/opencv.hpp>
double alfa;
int alfa_slider = 0;
int alfa_slider_max = 100;
int top_slider = 0;
int top_slider_max = 100;
cv::Mat image1, image2, blended;
cv::Mat imageTop;
char TrackbarName[50];
void on_trackbar_blend(int, void*){
alfa = (double) alfa_slider/alfa_slider_max ;
cv::addWeighted(image1, 1-alfa, imageTop, alfa, 0.0, blended);
cv::imshow("addweighted", blended);
}
void on_trackbar_line(int, void*){
image1.copyTo(imageTop);
int limit = top_slider*255/100;
if(limit > 0){
cv::Mat tmp = image2(cv::Rect(0, 0, 256, limit));
tmp.copyTo(imageTop(cv::Rect(0, 0, 256, limit)));
}
on_trackbar_blend(alfa_slider,0);
}
int main(int argvc, char** argv){
image1 = cv::imread("blend1.jpg");
image2 = cv::imread("blend2.jpg");
image2.copyTo(imageTop);
cv::namedWindow("addweighted", 1);
std::sprintf( TrackbarName, "Alpha x %d", alfa_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&alfa_slider,
alfa_slider_max,
on_trackbar_blend );
on_trackbar_blend(alfa_slider, 0 );
std::sprintf( TrackbarName, "Scanline x %d", top_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&top_slider,
top_slider_max,
on_trackbar_line );
on_trackbar_line(top_slider, 0 );
cv::waitKey(0);
return 0;
}
Para compilar e executar o programa addweighted.cpp, salve-o juntamente com o arquivo Makefile em um diretório juntamente com as imagens exemplos/blend1.jpg e exemplos/blend2.jpg e execute a seguinte seqüência de comandos:
$ make addweighted
$ ./addweighted
A saída do programa addweighted apresentará uma janela com duas barras de controle: uma que regula o valor de \$alpha\$ e outra que indica a região que será copiada de uma das imagens de entrada na imagem da composição.
Utilizando os recursos do exemplo, é possível conceber uma função de
ponderação para combinar a imagem original com sua versão borrada por
um filtro da média. Entretanto, o desfoque não deve alterar a região
central da imagem final para que o efeito do tiltshift funcione.
Tal processo pode ser modelado usando uma função que define a região de desfoque ao longo do eixo vertical da imagem. Uma possível função que modela esse efeito é dada por
\$\alpha (x) = \frac{1}{2} ( \tanh \frac{x-l1}{d}-tanh\frac{x-l2}{d} )\$
Onde \$l1\$ e \$l2\$ são as linhas cujo valor de \$\alpha\$ assume valor em torno de 0.5, caso os dois valores possuam uma distância adequada um do outro, e \$d\$ indica a força do decaimento da região totalmente oriunda da imagem original para a região totalmente oriunda da imagem borrada.
Para valores \$l1 = -20 \$, \$l2 = 30\$, e \$d = 6\$, por exemplo, a função de ponderação se comportaria como ilustrado na Figura 20.
Assumindo que \$\alpha(x)\$ pondere a imagem original (denotada por
stem \$f(x,y)\$) e \$1-\alpha(x)\$ pondere a imagem borrada
(denotada por \$bf(x,y)\$), a composição \$g(x,y) = \alpha(x)
f(x,y) + (1-\alpha(x)) bf(x,y)\$ produzirá o efeito de tiltshift
desejado.
O processo de ponderação pode ser realizado por intermédio da função
multiply() do OpenCV, destinada à multiplicação de matrizes
elemento-a-elemento. Cria-se a imagem que irá ponderar as linhas da
imagem original e seu negativo irá ponderar as linhas da imagem
borrada. A combinação linear dessas duas imagens fara o efeito
simulado de tiltshift. A Figura 21 ilustra
possíveis imagens que poderiam ser usadas para ponderação no
processo. A da esquerda ponderaria a imagem original e a da direita a
imagem borrada.
tiltshift8.1. Exercícios
-
Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa
tiltshift.cpp. Três ajustes deverão ser providos na tela da interface:-
um ajuste para regular a altura da região central que entrará em foco;
-
um ajuste para regular a força de decaimento da região borrada;
-
um ajuste para regular a posição vertical do centro da região que entrará em foco. Finalizado o programa, a imagem produzida deverá ser salva em arquivo.
-
-
Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa
tiltshiftvideo.cpp. Tal programa deverá ser capaz de processar um arquivo de vídeo, produzir o efeito de tilt-shift nos quadros presentes e escrever o resultado em outro arquivo de vídeo. A ideia é criar um efeito de miniaturização de cenas. Descarte quadros em uma taxa que julgar conveniente para evidenciar o efeito de stop motion, comum em vídeos desse tipo.
9. A Tranformada Discreta de Fourier
O objetivo desse capítulo é apresentar a Transformada Discreta de Fourier bidimensional. A Transformada de Fourier é uma transformada capaz de expressar um sinal contínuo como uma combinação de funções de base senoidais ponderadas por coeficientes. Em se tratando de sinais discretos, como é o caso de imagens digitais, a Transformada Discreta de Fourier (ou DFT) é a transformada utilizada.
Para uma imagem digital, a Transformada Discreta de Fourier é capaz de fornecer uma representação alternativa dessa imagem no domínio da frequência, evidenciando degradações que não são facilmente tratadas no domínio espacial. Exemplos de problemas dessa natureza são as interferências periódicas nas transmissões de sinais analógicos, ou repetição de padrões presentes em figuras antigas ou fotos de jornais.
Um exemplo de fotografia corrompida por um padrão senoidal é mostrada na Figura 22. Note que existe uma espécie de grade de pontos presentes nessa imagem.
O espectro de magnitude da Transformada Discreta de Fourier da imagem da Figura 22 é mostrada na Figura 23. Perceba que há um conjunto de manchas simétricas que surgem longe dos eixos, destacando-se do restante do sinal transformado. São essas contribuições as causadoras da grade de pontos e podem ser removidas com o uso de filtros adequados.
Para realizar o cálculo da Transformada Discreta de Fourier, é necessário realizar uma série de passos que envolvem a preparação da matriz complexa que deve ser fornecida à função de cálculo da DFT.
Para ilustrar o uso da Transformada Discreta de Fourier, considere o exemplo mostrado na Listagem 11.
#include <iostream>
#include <vector>
#include <opencv2/opencv.hpp>
void swapQuadrants(cv::Mat& image) {
cv::Mat tmp, A, B, C, D;
// se a imagem tiver tamanho impar, recorta a regiao para o maior
// tamanho par possivel (-2 = 1111...1110)
image = image(cv::Rect(0, 0, image.cols & -2, image.rows & -2));
int centerX = image.cols / 2;
int centerY = image.rows / 2;
// rearranja os quadrantes da transformada de Fourier de forma que
// a origem fique no centro da imagem
// A B -> D C
// C D B A
A = image(cv::Rect(0, 0, centerX, centerY));
B = image(cv::Rect(centerX, 0, centerX, centerY));
C = image(cv::Rect(0, centerY, centerX, centerY));
D = image(cv::Rect(centerX, centerY, centerX, centerY));
// swap quadrants (Top-Left with Bottom-Right)
A.copyTo(tmp);
D.copyTo(A);
tmp.copyTo(D);
// swap quadrant (Top-Right with Bottom-Left)
C.copyTo(tmp);
B.copyTo(C);
tmp.copyTo(B);
}
int main(int argc, char** argv) {
cv::Mat image, padded, complexImage;
std::vector<cv::Mat> planos;
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
// expande a imagem de entrada para o melhor tamanho no qual a DFT pode ser
// executada, preenchendo com zeros a lateral inferior direita.
int dft_M = cv::getOptimalDFTSize(image.rows);
int dft_N = cv::getOptimalDFTSize(image.cols);
cv::copyMakeBorder(image, padded, 0, dft_M - image.rows, 0, dft_N - image.cols, cv::BORDER_CONSTANT, cv::Scalar::all(0));
// prepara a matriz complexa para ser preenchida
// primeiro a parte real, contendo a imagem de entrada
planos.push_back(cv::Mat_<float>(padded));
// depois a parte imaginaria com valores nulos
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
// combina os planos em uma unica estrutura de dados complexa
cv::merge(planos, complexImage);
// calcula a DFT
cv::dft(complexImage, complexImage);
swapQuadrants(complexImage);
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
// some uma constante para evitar log(0)
// log(1 + sqrt(Re(DFT(image))^2 + Im(DFT(image))^2))
magn += cv::Scalar::all(1);
// calcula o logaritmo da magnitude para exibir
// com compressao de faixa dinamica
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
// exibe as imagens processadas
cv::imshow("Imagem", image);
cv::imshow("Espectro de magnitude", magn);
cv::imshow("Espectro de fase", fase);
cv::waitKey();
return EXIT_SUCCESS;
}
9.1. Descrição do programa dftimage.cpp
A operação de cálculo da Transformada Discreta de Fourier em OpenCV pode ser realizada pelo seguinte conjunto de passos:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.
-
Cálculo do espectro de magnitude e de fase da transformada.
-
Compressão de faixa dinâmica do espectro de magnitude para melhor visualização.
-
Exibição dos espectros de magnitude e fase da transformada.
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
A imagem a ser processada é lida do disco e armazenada na variável image em
formato de escala de cinza. Caso a imagem não seja lida com sucesso, o programa
finaliza, apresentando uma mensagem de erro. A conversão para escala de cinza é
necessária pois o programa foi desenvolvido para processar imagens em escala de
cinza.
dft_M = cv::getOptimalDFTSize(image.rows);
dft_N = cv::getOptimalDFTSize(image.cols);
A função getOptimalDFTSize() identifica os melhores valores com base
no tamanho fornecido para acelerar o processo de cálculo da DFT com
base em algum algoritmo otimizado. Segundo a documentação do OpenCV,
valores múltiplos de dois, três e cinco produzem resultados
melhores. Os valores de tamanho ideal para a quantidade de linhas e
colunas da imagem são armazenados nas variáveis dft_M e dft_N,
respectivamente.
cv::copyMakeBorder(image, padded, 0,
dft_M - image.rows, 0,
dft_N - image.cols,
cv::BORDER_CONSTANT, cv::Scalar::all(0));
A função copyMakeBorder() cria uma versão da imagem fornecida com
uma borda preenchida com zeros e ajustada ao tamanho ótimo para
cálculo da DFT, conforme indicado pelo uso da função
getOptimalDFTSize(). Para uma imagem image fornecida, a saída é
produzida na imagem padded. Caso a imagem fornecida já
possua dimensões apropriadas, a imagem de saída será igual à de
entrada.
planos.push_back(cv::Mat_<float>(padded));
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
cv::merge(planos, complexImage);
Esse trecho de código prepara a matriz complexa que será fornecida à função de
cálculo do dft. Ambas são enfileiradas em um vetor de matrizes e a função
merge() se encarrega de produzir a matriz complexa a partir das matrizes
presentes no vetor planos.
// calcula o dft
cv::dft(complexImage, complexImage);
O cálculo do DFT é realizado. Perceba que tanto a matriz de entrada quanto a de saída passadas como parâmetro podem ser a mesma.
// realiza a troca de quadrantes
swapQuadrants(complexImage);
Finalizado o cálculo da DFT, a função swapQuadrants() realiza a
troca de quadrantes. Para melhor visualização do espectro de magnitude da transformada, é necessário
que o sinal transformado seja deslocado de modo que a origem do sinal fique
posicionada no centro da imagem, como ilustra a Figura 24.
A operação de troca de quadrantes realizada pela função swapQuadrants(). Ela
recebe a referência para a matriz que contém a imagem transformada e
troca seus quadrantes. Caso a imagem possua tamanho ímpar, ela é
diminuída de tamanho em um pixel para que a troca dos quadrantes
seja feita usando tamanhos imagens de iguais. Normalmente, trata-se a
imagem que será submetida ao cálculo da DFT para que possua dimensões
de ordem par, de sorte que essa linha não deverá alterar o tamanho das
imagens usualmente fornecidas.
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
Terminada a troca de quadrantes, a imagem transformada é separada em suas
componentes real e imaginária com a função split(). As componentes são então
armazenadas na forma de matrizes no vetor planos.
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
A função cartToPolar() calcula o espectro de magnitude e de fase a partir das
componentes real e imaginária da imagem transformada. Esta função foi escolhida
especificamente para o exemplo porque já consegue obter os dois espectros ao
mesmo tempo. Entretanto, perceba que logo após a normalização do espectro de
fase, há uma chamada à função magnitude().
A função magnitude() calcula somente o espectro de magnitude a partir das
componentes real e imaginária e, caso o espectro de fase não seja necessário
para a análise do sinal transformado, esta é a função mais indicada para o
cálculo do espectro de magnitude.
magn += cv::Scalar::all(1);
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
Esse trecho de código serve para realizar a compressão de faixa dinâmica e
normalização do espectro de magnitude na faixa \$0,1\$ para fins de exibição.
A compressão de faixa dinâmica é logaritmica e, para evitar erros de cálculo nas
situações em que algum dos elementos da matriz magn seja igual a zero, é
somado um valor constante a todos os elementos da matriz igual a um.
9.2. Exercícios
-
Utilizando os programa exemplos/dftimage.cpp, calcule e apresente o espectro de magnitude da imagem Figura 7.
-
Compare o espectro de magnitude gerado para a figura Figura 7 com o valor teórico da transformada de Fourier da senóide.
-
Usando agora o filestorage.cpp, mostrado na Listagem 4 como referência, adapte o programa exemplos/dftimage.cpp para ler a imagem em ponto flutuante armazenada no arquivo YAML equivalente (ilustrado na Listagem 5).
-
Compare o novo espectro de magnitude gerado com o valor teórico da transformada de Fourier da senóide. O que mudou para que o espectro de magnitude gerado agora esteja mais próximo do valor teórico? Porque isso aconteceu?
10. Filtragem no Domínio da Frequência
O objetivo da filtragem no domínio da frequência é remover ruídos e distorções geralmente de natureza periódica numa imagem. Neste capítulo, veremos como criar um filtro de frequência e aplicá-lo a uma imagem utilizando a DFT. O filtro de frequência é uma matriz que possui o mesmo tamanho da imagem e que é multiplicada pela transformada de Fourier da imagem para filtrar eventuais problemas que existam na imagem.
O processo de filtragem envolve uma sequência de passos cuja parte delas já foi explorada em outra lição. Os passos para realizar o processo de filtragem são:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.
-
Criação de um filtro de frequência.
-
Multiplicação do filtro de frequência pela imagem transformada.
-
Troca de quadrantes para que a origem da imagem transformada volte para o canto superior esquerdo.`
-
Remoção do padding da imagem (caso necessário).
-
Visualização da imagem filtrada.
Nessa sequência de passos, o processo de filtragem inicial com a criação do
filtro \$H(u,v)\$, tal que a imagem filtrada é dada por \$G(u,v) = H(u,v)
\cdot F(u,v)\$, onde \$F(u,v)\$ é a transformada de Fourier da imagem de
entrada e \$G(u,v)\$ é a transformada de Fourier da imagem filtrada. Esse
produto de matrizes é realizado elemento a elemento, e é implementado no OpenCV
pela função mulSpectrums().
Para compilar e executar o programa dftfilter.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make dftfilter
$ ./dftfilter biel.png
A saída do programa dftfilter é mostrado na Figura 25.
10.1. Descrição do programa dftfilter.cpp
O trecho de código a seguir mostra a chamada da função de criação do filtro de frequência e a aplicação do filtro na imagem.
cv::Mat filter;
makeFilter(complexImage, filter);
cv::mulSpectrums(complexImage, filter, complexImage, 0);
A função makeFilter() é responsável por criar o filtro de frequência. Ela
recebe a imagem transformada e a matriz que será preenchida com o filtro de
frequência é retornada no segundo parâmetro, que é passado na forma de uma
referência.
void makeFilter(const cv::Mat &image, cv::Mat &filter){
cv::Mat_<float> filter2D(image.rows, image.cols);
int centerX = image.cols / 2;
int centerY = image.rows / 2;
int radius = 20;
for (int i = 0; i < image.rows; i++) {
for (int j = 0; j < image.cols; j++) {
if (pow(i - centerY, 2) + pow(j - centerX, 2) <= pow(radius, 2)) { (1)
filter2D.at<float>(i, j) = 1;
} else {
filter2D.at<float>(i, j) = 0;
}
}
}
cv::Mat planes[] = {cv::Mat_<float>(filter2D),
cv::Mat::zeros(filter2D.size(), CV_32F)}; (2)
cv::merge(planes, 2, filter); (3)
}
| 1 |
A função makefilter() cria um filtro ideal de tamanho igual ao da imagem. Do
centro da matriz até uma distância de 20 pixels, o valor filtro é igual a 1.
Fora desse raio, o valor do filtro é igual a 0. O tipo de dado usado para criar
a matriz é float, pois é o tipo de dado usado para armazenar os valores da DFT.
|
| 2 |
Para criar o filtro de frequência, é necessário criar uma matriz com dois
canais, um para a parte real e outro para a parte imaginária. Daí a criação do
vetor de matrizes planes[] e…
|
| 3 | A chamada da função merge() para criar a matriz de dois canais. |
// calcula a DFT inversa
swapQuadrants(complexImage);
cv::idft(complexImage, complexImage);
cv::split(complexImage, planos);
// recorta a imagem filtrada para o tamanho original
// selecionando a regiao de interesse (roi)
cv::Rect roi(0, 0, image.cols, image.rows);
cv::Mat result = planos[0](roi);
A última parte do código mostra como recuperar a imagem filtrada. A transformada
de Fourier inversa é calculada com a função idft(). A função split() divide
a imagem multicanal em duas matrizes, uma para a parte real (planos[0]) e
outra para a parte imaginária (planos[1]).
A imagem filtrada é obtida selecionando a região de interesse da imagem
correspondente ao tamanho original da imagem de entrada usando um objeto da
classe Rect para esse fim. A imagem filtrada é armazenada na variável result
e posteriormente normalizada para exibição.
10.2. Exercícios
-
Utilizando o programa exemplos/dftfilter.cpp como referência, implemente o filtro homomórfico para melhorar imagens com iluminação irregular. Crie uma cena mal iluminada e ajuste os parâmetros do filtro homomórfico para corrigir a iluminação da melhor forma possível. Assuma que a imagem fornecida é em tons de cinza.
11. Detecção de bordas com o algoritmo de Canny
O detector de bordas de Canny é sabidamente reconhecido como um dos mais rápidos e eficientes algoritmos para encontrar descontinuidades em uma imagem. Ele produz como resultado uma imagem binária contendo os pontos de borda obtidos a partir de uma imagem, para um conjunto de parâmetros de configuração.
Em linhas gerais, o algoritmo de Canny procura descobrir bordas situadas em máximos locais do gradiente de uma image, e pode ser sumarizado pelos seguintes passos:
-
Convolução com o filtro Gaussiano, cálculo da magnitude e ângulo do gradiente.
-
Afinação das cristas largas do gradiente.
-
Classificação dos pontos quanto às orientações Horizontal, Vertical, \(+45^\text{o}\), e \(-45^\text{o}\) (intervalos de \(\pm 22.5^\text{o}\)).
-
Para os vizinhos na orientação determinada para o pixel, verificar os seus gradientes.
-
Supressão de não máximos: se o valor da magnitude do gradiente \(M(x,y)\) for inferior a pelo menos um de seus vizinhos, faça \(g_N(x,y)=0\); caso contrário, faça \(g_N(x,y) = M(x,y)\). A imagem \(g_N(x,y)\) é a imagem com supressão.
-
-
Limiarização com histerese é usada para a quebra do contorno (borda tracejada).
-
Dois limiares \(T_1\) e \(T_2\). \(T_1 > T_2\) são usados.
-
Se o pixel é tal que \(g_N(x,y) \ge T_1\), é assumido como ponto de borda forte.
-
Para os pixels restantes, aqueles em que \(g_N(x,y) \ge T_2\), são assumidos como ponto de borda fraco.
-
Para todos os vizinhos dos pontos de borda fraco, procurar nos seus 8-vizinhos se há algum ponto de borda forte. Caso haja, este é marcado como parte da fronteira.
-
Sugestão de Canny: \(T_H/T_L = 3/1\), ou \(T_H/T_L =2/1\)
-
Um exemplo de aplicação desse algoritmo na imagem da Figura 26 é mostrado na Figura 27. Observe que as bordas encontradas são bem localizadas e geralmente possuem espessura igual a 1.
O programa que gerou essa imagem é mostrado na Listagem 12.
#include <iostream>
#include "opencv2/opencv.hpp"
int top_slider = 10;
int top_slider_max = 200;
char TrackbarName[50];
cv::Mat image, border;
void on_trackbar_canny(int, void*){
cv::Canny(image, border, top_slider, 3*top_slider);
cv::imshow("Canny", border);
}
int main(int argc, char**argv){
image= cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
sprintf( TrackbarName, "Threshold inferior", top_slider_max );
cv::namedWindow("Canny",1);
cv::createTrackbar( TrackbarName, "Canny",
&top_slider,
top_slider_max,
on_trackbar_canny );
on_trackbar_canny(top_slider, 0 );
cv::waitKey();
cv::imwrite("cannyborders.png", border);
return 0;
}
Para compilar e executar o programa canny.cpp, salve-o juntamente com os arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make canny
$ ./canny biel.png
O programa disponibilizará uma scrollbar que regula o valor do
limiar \(T_1\). O valor do limiar \(T_2\) é
determinado automaticamente usando a proporção \(T_1 = 3
T_1\). Ao ser finalizado - quando uma tecla é pressionada - o programa
escreve a imagem de bordas no arquivo de nome cannyborders.png.
Valores diferentes para o limiar escolhido produzem imagens de bordas diferentes.
A função de destaque nesse programa exemplo é apenas a função
Canny().
cv::Canny(image, border, top_slider, 3*top_slider);
Os dois primeiros argumentos indicam a imagem a ser processada, a
matriz onde a imagem de bordas será escrita, e os limiares
\(T_1\) e \(T_2\), neste caso representado pelas
quantidades top_slider e 3*top_slider.
11.1. Canny e a arte com pontilhismo
O algoritmo de Canny de fato é útil para diversas aplicações em processamento de imagens e visão artificial. Informações de bordas podem ser usadas para melhorar algoritmos de segmentação automática ou para encontrar objetos em cenas e pontos de interesse.
Entretanto, nesta lição, a proposta de uso do algoritmo é para desenvolver arte digital. A ideia é usar uma imagem de referência para criar uma nova imagem usando efeitos artísticos pontilhistas.
O pontilhismo é uma técnica de desenho impressionista onde o quadro é pintado usando apenas pontos. Um dos artistas pioneiros nessa técnica foi George Seurat . Vários dos seus trabalhos podem ser vistos online no site georgesseurat.org.
Simular no computador um efeito pontilhista não é muito trabalhoso. Uma estratégia simples é utilizar uma imagem de referência e criar uma outra imagem desenhada usando pequenos círculos. Em suma, percorre-se a imagem de referência e para cada pixel, desenha-se um círculo com a mesma cor na posição correspondente na imagem pontilhista.
Efeitos pontilhistas interessantes podem ser criados com variantes simples dessa técnica. Exemplo: pular sequências de pixels na imagem de referência para dar a impressão de que os pontos estão separados na tela - isso é bastante comum na arte pontilhista. Outro efeito interessante é realizar deslocamentos aleatórios nos centros dos círculos, para que a imagem gerada permaneca menos artificial. Finalmente, é razoável percorrer a matriz de referência usando uma sequência aleatória, principalmente quando a técnica pontilhista realiza a sobreposição de círculos.
Um exemplo de imagem pontilhista é mostrada na Figura 28.
O programa que gerou essa imagem é mostrado na Listagem 13.
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <opencv2/opencv.hpp>
#include <vector>
#define STEP 5
#define JITTER 3
#define RAIO 3
int main(int argc, char** argv) {
std::vector<int> yrange;
std::vector<int> xrange;
cv::Mat image, frame, points;
int width, height, gray;
int x, y;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
std::srand(std::time(0));
if (image.empty()) {
std::cout << "Could not open or find the image" << std::endl;
return -1;
}
width = image.cols;
height = image.rows;
xrange.resize(height / STEP);
yrange.resize(width / STEP);
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for (uint i = 0; i < xrange.size(); i++) {
xrange[i] = xrange[i] * STEP + STEP / 2;
}
for (uint i = 0; i < yrange.size(); i++) {
yrange[i] = yrange[i] * STEP + STEP / 2;
}
points = cv::Mat(height, width, CV_8U, cv::Scalar(255));
std::random_shuffle(xrange.begin(), xrange.end());
for (auto i : xrange) {
std::random_shuffle(yrange.begin(), yrange.end());
for (auto j : yrange) {
x = i + std::rand() % (2 * JITTER) - JITTER + 1;
y = j + std::rand() % (2 * JITTER) - JITTER + 1;
gray = image.at<uchar>(x, y);
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
}
}
cv::imwrite("pontos.jpg", points);
return 0;
}
Para compilar e executar o programa pontilhismo.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make pontilhismo
$ ./pontilhismo biel.png
11.2. Descrição do programa pontilhismo.cpp
O programa pontilhismo.cpp não introduz novas funcionalidades da
biblioteca de programação OpenCV. Entretanto, algumas classes da
STL, a biblioteca padrão
de gabaritos do C++ estão presentes no código para facilitar a criação
de alguns efeitos. Logo, é importante discorrer um pouco sobre seu uso
no exemplo.
std::vector<int> yrange;
std::vector<int> xrange;
Define-se dois arrays de índices que servirão para identificar
elementos da imagem de referência. Os tamanhos dos arrays xrange e
yrange são determinados como frações da altura e da largura da
imagem, respectivamente. Isso é feito para que na geração da imagem
pontilhista, apenas alguns pontos sejam amostrados na imagem de
referência, evitando sobrecarga visual.
A grandeza STEP define o passo usado para varrer a imagem de
referência. No exemplo, usamos STEP igual a 5 pixels, ou seja,
considerando as duas dimensões da imagem, apenas 1 em cada
\(5 \times 5 = 25\) pixels de uma janela é usado para criar um
círculo.
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for(uint i=0; i<xrange.size(); i++){
xrange[i]= xrange[i]*STEP+STEP/2;
}
for(uint i=0; i<yrange.size(); i++){
yrange[i]= yrange[i]*STEP+STEP/2;
}
Os arrays xrange e yrange são preenchidos com valores sequenciais
iniciando em 0 e, em seguida, esses valores recebem um ganho igual a
STEP e um deslocamento STEP/2, para que o processo de amostragem
na imagem de referência se dê no centro da janela.
std::random_shuffle(xrange.begin(), xrange.end());
A função random_shuffle() recebe como parâmetros 2 iteradores: uma
para o início do array e outro para o final. Como resultado, a função
embaralha aleatoriamente todos seus elementos. Se observado, esse
processo é feito uma vez para o array de índices das linhas -
xrange - e, para cada linha, embaralha-se o array de índices das
colunas - yrange.
Os loops descritos por for(auto i : xrange) e for(auto j : yrange)
são construções na especificação C++11 e servem para fazer as
variáveis i e j assumirem, a cada passada no loop, os valores dos
arrays xrange e yrange de forma consecutiva.
x = i+rand()%(2*JITTER)-JITTER+1;
y = j+rand()%(2*JITTER)-JITTER+1;
O valor das coordenadas do ponto cujo tom de cinza será amostrado na
imagem de referência é determinado pela posição do centro da janela
mais um deslocamento aleatório em ambas as direções. Esse deslocamento
é determinado pela grandeza JITTER (igual a 3 pixels).
Variações das grandezas STEP e JITTER podem ser modificadas para
uso em imagens de tamanhos diferentes.
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
A função circle() é usada para traçar um círculo de raio
especificado em um ponto determinado pelo usuário. O círculo é
desenhado usando preenchimento sólido e, dada a presença do parâmetro
cv::LINE_AA, este será desenhado usando técnicas de antialiasing. Assim,
o círculo terá bordas não serrilhadas, produzindo um efeito visual
agradável na imagem pontilhista.
11.3. Exercícios
-
Utilizando os programas exemplos/canny.cpp e exemplos/pontilhismo.cpp como referência, implemente um programa
cannypoints.cpp. A idéia é usar as bordas produzidas pelo algoritmo de Canny para melhorar a qualidade da imagem pontilhista gerada. A forma como a informação de borda será usada é livre. Entretanto, são apresentadas algumas sugestões de técnicas que poderiam ser utilizadas:-
Desenhar pontos grandes na imagem pontilhista básica;
-
Usar a posição dos pixels de borda encontrados pelo algoritmo de Canny para desenhar pontos nos respectivos locais na imagem gerada.
-
Experimente ir aumentando os limiares do algoritmo de Canny e, para cada novo par de limiares, desenhar círculos cada vez menores nas posições encontradas. A Figura 29 foi desenvolvida usando essa técnica.
-
-
Escolha uma imagem de seu gosto e aplique a técnica que você desenvolveu.
-
Descreva no seu relatório detalhes do procedimento usado para criar sua técnica pontilhista.
12. Quantização vetorial com k-means
Algoritmos de quantização são um grupo de técnicas usadas para mapear os dados presentes em um conjunto grande em um conjunto menor de elementos. É normalmente usada para fins de compressão de dados. Quando um grande conjunto de pontos (vetores) é dividido em em grupos de tamanho menor, diz-se que tem uma quantização vetorial, onde cada grupo é representado por um centróide.
Dos vários algoritmos de quantização vetorial que podem ser encontrados na literatura, o k-means está entre os mais populares. É um algoritmo simples que particiona o espaço N-dimensional em células de Voronoi, onde cada célula é determinada por um centro. O conjunto de todos os pontos no espaço cuja distância para um dado centro é menor que para todos os outros centros define a célula.
O algoritmo k-means funciona conforme os seguintes passos:
-
Escolha \$k\$ como o número de classes para os vetores \$\mathbf{x}_i\$ de \$N\$ amostras, \$i=1,2,\cdots,N\$.
-
Escolha \$\mathbf{m}_1, \mathbf{m}_2,\cdots,\mathbf{m}_k\$ como aproximações iniciais para os centros das classes.
-
Classifique cada amostra \$\mathbf{x}_i\$ usando, por exemplo, um classificador de distância mínima (distância euclideana).
-
Recalcule as médias \$\mathbf{m}_j\$ usando o resultado do passo anterior.
-
Se as novas médias são consistentes (não mudam consideravelmente), finalize o algoritmo. Caso contrário, recalcule os centros e refaça a classificação.
Algo que se percebe do algoritmo k-means é que cada execução leva a um resultado diferente do resultado anterior. Embora o algoritmo normalmente estabilize, algumas execuções podem criar aglomerações melhores que outras. Logo, é comum executar o algoritmo algumas vezes e verificar qual execução gera melhor compactação dos dados. Uma das medidas de compactação - a usada pelo OpenCV - verifica a soma dos quadrados das distâncias dos pontos da amostra para seus respectivos centros.
O programa de referência utilizado para essa tarefa, kmeans.cpp, é mostrado na Listagem Kmeans.
#include <cstdlib>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
int nClusters = 8, nRodadas = 5;
cv::Mat rotulos, centros;
if (argc != 3) {
std::cout << "kmeans entrada.jpg saida.jpg\n";
exit(0);
}
cv::Mat img = cv::imread(argv[1], cv::IMREAD_COLOR);
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
for (int z = 0; z < 3; z++) {
samples.at<float>(y + x * img.rows, z) = img.at<cv::Vec3b>(y, x)[z];
}
}
}
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
cv::Mat rotulada(img.size(), img.type());
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
int indice = rotulos.at<int>(y + x * img.rows, 0);
rotulada.at<cv::Vec3b>(y, x)[0] = (uchar)centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y, x)[1] = (uchar)centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y, x)[2] = (uchar)centros.at<float>(indice, 2);
}
}
cv::imshow("kmeans", rotulada);
cv::imwrite(argv[2], rotulada);
cv::waitKey();
}
Para compilar e executar o programa kmeans.cpp, salve-o juntamente com o arquivo Makefile e a imagem sushi.jpg em um diretório e execute a seguinte seqüência de comandos:
$ make kmeans
$ ./kmeans sushi.jpg sushi-kmeans.jpg
A saída do programa kmeans é mostrado na Figura 30
12.1. Descrição do programa kmeans.cpp
O programa kmeans opera sobre a imagem fornecida como primeiro
argumento de modo a reduzir a quantidade de cores presentes na mesma
para um total de 6 cores (que pode ser ajustada pela variável
nClusters).
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
Uma matriz de amostras é criada para armazenar todas as cores dos pixels da imagem. É comum executar o k-means com uma amostra do espaço de entrada, mas utilizou-se a totalidade dos pixels imagem nesse exemplo.
A matriz samples possui um total de linhas igual ao total
de pixels da imagem fornecida e apenas três colunas. Cada coluna é
concebida para armazenar cada uma das componentes de cor (R, G e B)
dos pixels.
samples.at<float>(y + x*img.rows, z) = img.at<cv::Vec3b>(y,x)[z];
A cópia pixel a pixel, componente a componente de cor é realizada da imagem de entrada para a matriz de amostras.
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
A matriz com as amostras samples deve conter em cada linha uma das amostras a ser processada pela função disponível pelo opencv. nClusters informa a quantidade de aglomerados que se deseja obter. A matriz rotulos é um objeto do
tipo Mat preenchido com elementos do tipo int, onde cada elemento
identifica a classe à qual pertence a amostra na matriz samples. No
exemplo, um máximo de até 10000 iterações ou tolerância de 0.0001
devem ser atingidos para finalizar o algoritmo. O algoritmo é repetido
por uma quantidade de vezes definida por nRodadas. A rodada que
produz a menor soma de distâncias dos pontos para seus respectivos
centros é escolhida como vencedora. Os centros do algoritmo são
inicializados usando o algoritmo proposto por
Arthur2007. Finalmente,
as coordenadas dos centros são guardadas na matriz centros.
É importante perceber que tanto a matriz de amostras quanto a matriz
com os centros é definida como float para realizar a execução do
algoritmo. As aproximações geradas por matrizes inteiras levariam a
resultados incorretos do k-means.
rotulada.at<cv::Vec3b>(y,x)[0] = (uchar) centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y,x)[1] = (uchar) centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y,x)[2] = (uchar) centros.at<float>(indice, 2);
Por fim, uma versão quantizada da imagem de entrada é composta usando os centros obtidos na execução do k-means.
12.2. Exercícios
-
Utilizando o programa kmeans.cpp como exemplo prepare um programa exemplo onde a execução do código se dê usando o parâmetro
nRodadas=1e inciar os centros de forma aleatória usando o parâmetroKMEANS_RANDOM_CENTERSao invés deKMEANS_PP_CENTERS. Realize 10 rodadas diferentes do algoritmo e compare as imagens produzidas. Explique porque elas podem diferir tanto.
13. Filtragem de forma com morfologia matemática
A filtragem de forma é uma técnica de processamento de imagens que visa corrigir imperfeições relacionadas com a forma de objetos que compõem, como por exemplo pequenas regiões. Ela é realizada através de operações morfológicas que atuam sobre a forma de objetos na imagem, modificando a propriedade dos pixels conforme propriedades de uma vizinhança selecionada. Assim como na operação de convolução a máscara utilizada desempenha um papel fundamental no resultado do processo, na morfologia, o efeito da filtragem é controlada por um conjunto denominado elemento estruturante. O elemento estruturante normalmente é uma matriz binária que define a forma e o tamanho da vizinhança que será utilizada para a filtragem.
A Figura 31 mostra um exemplo típico de uma imagem corrompiada pelo ruído de forma. Perceba que a figura contém várias linhas que não são desejadas, tanto permeando a região de fundo escuro quanto a região branca que representa o objeto.
A filtragem de forma pode ser utilizada para corrigir esse problema usando o programa morfologia.cpp, que é mostrado na Listagem 15.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
cv::Mat image, erosao, dilatacao, abertura, fechamento, abertfecha;
cv::Mat str;
if (argc != 2) {
std::cout << "morfologia entrada saida\n";
}
image = cv::imread(argv[1], cv::IMREAD_UNCHANGED);
// image = cv::imread(argv[1], -1);
if(image.empty()) {
std::cout << "Erro ao carregar a imagem: " << argv[1] << std::endl;
return -1;
}
// elemento estruturante
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
// erosao
cv::erode(image, erosao, str);
// dilatacao
cv::dilate(image, dilatacao, str);
// abertura
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
// fechamento
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
// abertura -> fechamento
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
cv::Mat matArray[] = {erosao, dilatacao, abertura, fechamento, abertfecha};
cv::hconcat(matArray, 5, image);
cv::imshow("morfologia", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa morfologia.cpp, salve-o juntamente com o arquivo Makefile e a imagem morfoobjetos.png em um diretório e execute a seguinte seqüência de comandos:
$ make morfologia
$ ./morfologia morfoobjetos.png
A saída do programa morfologia é mostrado na Figura 32. Da esquerda para a direita são apresentadas as imagens resultantes das operações erosão, dilatação, abertura, fechamento e abertura seguida de fechamento, respectivamente.
13.1. Descrição do programa morfologia.cpp
O programa morfologia.cpp é um exemplo de aplicação da filtragem de forma. Ele recebe como parâmetro de entrada uma imagem e aplica as operações morfológicas de erosão, dilatação, abertura, fechamento e abertura seguida de fechamento. O programa utiliza a biblioteca OpenCV para carregar a imagem e exibir os resultados.
O primeiro passo da filtragem é criar o elemento estruturante que irá modelar as operações de filtragem morfológica.
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
A função getStructuringElement() cria um elemento estruturante com a forma de um retângulo de tamanho \$3 \times 3\$, todos preenchidos com o valor 1 (o elemento é representado como um objeto do tipo MAT). Essa marcação 1s indica que o elemento estruturante irá atuar sobre todos os pixels da vizinhança. A função getStructuringElement também pode ser utilizada para criar elementos estruturantes com formas diferentes, como por exemplo um elemento estruturante com forma de cruz, elipse ou disco, preenchido dentro do retângulo que limita o tamanho do elemento.
cv::erode(image, erosao, str);
Realiza a erosão da imagem image pelo elemento estruturante str.
cv::dilate(image, dilatacao, str);
Realiza a dilatação da imagem image pelo elemento estruturante str.
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
Realiza a abertura da imagem image pelo elemento estruturante str. A abertura é uma operação morfológica que consiste na erosão seguida de dilatação. Perceba que, ao contrário da erosão e dilatação, não há uma função específica para realizar a abertura. Para realizar a abertura é necessário chamar a função morphologyEx() passando como parâmetro o valor MORPH_OPEN para o parâmetro op. A função morphologyEx() é uma função genérica que permite realizar algumas das operações morfológicas mais comuns, como erosão, dilatação, abertura, fechamento, top hat, black hat e a transformada hit-or-miss.
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
Realiza o fechamento da imagem image pelo elemento estruturante str.
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
Realiza a abertura seguida de fechamento da imagem image pelo elemento estruturante str.
13.2. Exercícios
-
Um sistema de captura de imagens precisa realizar o reconhecimento de carateres de um visor de segmentos para uma aplicação industrial. O visor mostra caracteres como estes apresentados na Figura 33.
Ocorre que o software de reconhecimento de padrões apresenta dificuldades de reconhecer os dígitos em virtude da separação existente entre os segmentos do visor. Idealmente, o software deveria reconhecer os dígitos como na Figura 34.
Usando o programa morfologia.cpp como referência, crie um programa que resolva o problema da pré-filtragem de forma para reconhecimento dos caracteres usando operações morfológicas. Você poderá usar as imagens digitos-1.png, digitos-2.png, digitos-3.png, digitos-4.png e digitos-5.png para testar seu programa. Cuidado para deixar o ponto decimal separado dos demais dígitos para evitar um reconhecimento errado do número no visor.